fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。 验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
ALTER TABLE gateway_identity_onboarding_exchanges
|
||||
DROP CONSTRAINT gateway_identity_onboarding_exchanges_status_check;
|
||||
|
||||
ALTER TABLE gateway_identity_onboarding_exchanges
|
||||
ADD CONSTRAINT gateway_identity_onboarding_exchanges_status_check
|
||||
CHECK (status IN ('metadata_pending','preparing','ready','credentials_saved','completed','failed','expired','cancelled')),
|
||||
ADD COLUMN cleanup_status text NOT NULL DEFAULT 'none',
|
||||
ADD COLUMN cancelled_at timestamptz,
|
||||
ADD COLUMN cleanup_completed_at timestamptz;
|
||||
|
||||
ALTER TABLE gateway_identity_onboarding_exchanges
|
||||
ADD CONSTRAINT gateway_identity_onboarding_cleanup_status_check
|
||||
CHECK (cleanup_status IN ('none','pending','completed')),
|
||||
ADD CONSTRAINT gateway_identity_onboarding_cleanup_lifecycle_check
|
||||
CHECK (
|
||||
(status <> 'cancelled' AND cleanup_status = 'none' AND cancelled_at IS NULL AND cleanup_completed_at IS NULL) OR
|
||||
(status = 'cancelled' AND cancelled_at IS NOT NULL AND (
|
||||
(cleanup_status = 'pending' AND cleanup_completed_at IS NULL) OR
|
||||
(cleanup_status = 'completed' AND cleanup_completed_at IS NOT NULL)
|
||||
))
|
||||
);
|
||||
|
||||
UPDATE gateway_identity_onboarding_exchanges
|
||||
SET last_error_category = 'pairing_step_failed'
|
||||
WHERE last_error_category IS NOT NULL
|
||||
AND last_error_category NOT IN (
|
||||
'metadata_submission_failed','exchange_status_unavailable','credential_delivery_failed',
|
||||
'exchange_completion_failed','exchange_expired','remote_exchange_failed','remote_state_invalid',
|
||||
'security_event_configuration_invalid','security_event_connection_conflict',
|
||||
'security_event_discovery_failed','security_event_management_token_failed',
|
||||
'security_event_stream_create_failed','security_event_stream_response_invalid',
|
||||
'security_event_receiver_activation_failed','security_event_preparation_failed',
|
||||
'security_event_retirement_pending','pairing_step_failed',
|
||||
'cleanup_revision_unavailable','cleanup_security_event_unavailable',
|
||||
'cleanup_security_event_configuration_invalid','cleanup_security_event_connection_conflict',
|
||||
'cleanup_security_event_discovery_failed','cleanup_security_event_management_token_failed',
|
||||
'cleanup_security_event_stream_create_failed','cleanup_security_event_stream_response_invalid',
|
||||
'cleanup_security_event_receiver_activation_failed','cleanup_security_event_preparation_failed',
|
||||
'cleanup_security_event_retirement_pending','cleanup_security_event_connection_cleanup_failed',
|
||||
'cleanup_security_event_secret_cleanup_failed','cleanup_security_event_failed',
|
||||
'cleanup_secret_store_failed','cleanup_finalize_failed'
|
||||
);
|
||||
|
||||
ALTER TABLE gateway_identity_onboarding_exchanges
|
||||
ADD CONSTRAINT gateway_identity_onboarding_error_category_check
|
||||
CHECK (last_error_category IS NULL OR last_error_category IN (
|
||||
'metadata_submission_failed','exchange_status_unavailable','credential_delivery_failed',
|
||||
'exchange_completion_failed','exchange_expired','remote_exchange_failed','remote_state_invalid',
|
||||
'security_event_configuration_invalid','security_event_connection_conflict',
|
||||
'security_event_discovery_failed','security_event_management_token_failed',
|
||||
'security_event_stream_create_failed','security_event_stream_response_invalid',
|
||||
'security_event_receiver_activation_failed','security_event_preparation_failed',
|
||||
'security_event_retirement_pending','pairing_step_failed',
|
||||
'cleanup_revision_unavailable','cleanup_security_event_unavailable',
|
||||
'cleanup_security_event_configuration_invalid','cleanup_security_event_connection_conflict',
|
||||
'cleanup_security_event_discovery_failed','cleanup_security_event_management_token_failed',
|
||||
'cleanup_security_event_stream_create_failed','cleanup_security_event_stream_response_invalid',
|
||||
'cleanup_security_event_receiver_activation_failed','cleanup_security_event_preparation_failed',
|
||||
'cleanup_security_event_retirement_pending','cleanup_security_event_connection_cleanup_failed',
|
||||
'cleanup_security_event_secret_cleanup_failed','cleanup_security_event_failed',
|
||||
'cleanup_secret_store_failed','cleanup_finalize_failed'
|
||||
));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_identity_onboarding_cleanup
|
||||
ON gateway_identity_onboarding_exchanges(cleanup_status, updated_at)
|
||||
WHERE cleanup_status = 'pending';
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS gateway_identity_secret_cleanup_queue (
|
||||
secret_ref text PRIMARY KEY CHECK (secret_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'),
|
||||
not_before timestamptz NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
claim_token uuid,
|
||||
lease_expires_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT gateway_identity_secret_cleanup_status_check
|
||||
CHECK (status IN ('pending','claimed')),
|
||||
CONSTRAINT gateway_identity_secret_cleanup_claim_check
|
||||
CHECK (
|
||||
(status = 'pending' AND claim_token IS NULL AND lease_expires_at IS NULL) OR
|
||||
(status = 'claimed' AND claim_token IS NOT NULL AND lease_expires_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_identity_secret_cleanup_due
|
||||
ON gateway_identity_secret_cleanup_queue(status, not_before, lease_expires_at, updated_at);
|
||||
@@ -0,0 +1,29 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_identity_pairing_start_reservation (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
attempt_id uuid NOT NULL UNIQUE,
|
||||
state text NOT NULL DEFAULT 'starting' CHECK (state IN ('starting','paired')),
|
||||
revision_id uuid UNIQUE REFERENCES gateway_identity_configuration_revisions(id) ON DELETE RESTRICT,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT gateway_identity_pairing_start_state_check CHECK (
|
||||
(state='starting' AND revision_id IS NULL) OR
|
||||
(state='paired' AND revision_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_identity_pairing_start_expiry
|
||||
ON gateway_identity_pairing_start_reservation(state,expires_at);
|
||||
|
||||
INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,revision_id,expires_at)
|
||||
SELECT true,exchange.id,'paired',revision.id,exchange.expires_at
|
||||
FROM gateway_identity_configuration_revisions revision
|
||||
JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id
|
||||
WHERE revision.state IN ('draft','validated','failed')
|
||||
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
|
||||
ORDER BY revision.created_at DESC,exchange.created_at DESC
|
||||
LIMIT 1
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,34 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
ALTER TABLE gateway_identity_secret_cleanup_queue
|
||||
ADD COLUMN IF NOT EXISTS status text,
|
||||
ADD COLUMN IF NOT EXISTS claim_token uuid,
|
||||
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
|
||||
|
||||
ALTER TABLE gateway_identity_secret_cleanup_queue
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_secret_cleanup_status_check,
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_secret_cleanup_claim_check;
|
||||
|
||||
UPDATE gateway_identity_secret_cleanup_queue
|
||||
SET status='pending',claim_token=NULL,lease_expires_at=NULL,updated_at=now()
|
||||
WHERE status IS NULL
|
||||
OR status NOT IN ('pending','claimed')
|
||||
OR (status='pending' AND (claim_token IS NOT NULL OR lease_expires_at IS NOT NULL))
|
||||
OR (status='claimed' AND (claim_token IS NULL OR lease_expires_at IS NULL));
|
||||
|
||||
ALTER TABLE gateway_identity_secret_cleanup_queue
|
||||
ALTER COLUMN status SET DEFAULT 'pending',
|
||||
ALTER COLUMN status SET NOT NULL,
|
||||
ADD CONSTRAINT gateway_identity_secret_cleanup_status_check
|
||||
CHECK (status IN ('pending','claimed')),
|
||||
ADD CONSTRAINT gateway_identity_secret_cleanup_claim_check
|
||||
CHECK (
|
||||
(status='pending' AND claim_token IS NULL AND lease_expires_at IS NULL) OR
|
||||
(status='claimed' AND claim_token IS NOT NULL AND lease_expires_at IS NOT NULL)
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS idx_gateway_identity_secret_cleanup_due;
|
||||
|
||||
CREATE INDEX idx_gateway_identity_secret_cleanup_due
|
||||
ON gateway_identity_secret_cleanup_queue(status,not_before,lease_expires_at,updated_at);
|
||||
@@ -0,0 +1,89 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
-- 0071 was briefly released with only the singleton reservation lease fields.
|
||||
-- Add the durable lifecycle columns without rewriting that already-applied
|
||||
-- migration, then reconcile the singleton with the canonical outstanding
|
||||
-- pairing (when one exists).
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
ADD COLUMN IF NOT EXISTS state text,
|
||||
ADD COLUMN IF NOT EXISTS revision_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS updated_at timestamptz;
|
||||
|
||||
UPDATE gateway_identity_pairing_start_reservation
|
||||
SET state=CASE WHEN revision_id IS NULL THEN 'starting' ELSE 'paired' END,
|
||||
updated_at=COALESCE(updated_at,created_at,now())
|
||||
WHERE state IS NULL
|
||||
OR state NOT IN ('starting','paired')
|
||||
OR (state='starting' AND revision_id IS NOT NULL)
|
||||
OR (state='paired' AND revision_id IS NULL)
|
||||
OR updated_at IS NULL;
|
||||
|
||||
WITH canonical_pairing AS (
|
||||
SELECT exchange.id AS attempt_id,
|
||||
revision.id AS revision_id,
|
||||
exchange.expires_at
|
||||
FROM gateway_identity_configuration_revisions revision
|
||||
JOIN gateway_identity_onboarding_exchanges exchange
|
||||
ON exchange.revision_id=revision.id
|
||||
WHERE revision.state IN ('draft','validated','failed')
|
||||
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
|
||||
ORDER BY revision.created_at DESC,exchange.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
INSERT INTO gateway_identity_pairing_start_reservation(
|
||||
singleton,attempt_id,state,revision_id,expires_at,updated_at
|
||||
)
|
||||
SELECT true,attempt_id,'paired',revision_id,expires_at,now()
|
||||
FROM canonical_pairing
|
||||
ON CONFLICT (singleton) DO UPDATE
|
||||
SET attempt_id=EXCLUDED.attempt_id,
|
||||
state=EXCLUDED.state,
|
||||
revision_id=EXCLUDED.revision_id,
|
||||
expires_at=EXCLUDED.expires_at,
|
||||
updated_at=now();
|
||||
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
ALTER COLUMN state SET DEFAULT 'starting',
|
||||
ALTER COLUMN state SET NOT NULL,
|
||||
ALTER COLUMN updated_at SET DEFAULT now(),
|
||||
ALTER COLUMN updated_at SET NOT NULL;
|
||||
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_pairing_start_state_value_check,
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_pairing_start_state_check;
|
||||
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
ADD CONSTRAINT gateway_identity_pairing_start_state_value_check
|
||||
CHECK (state IN ('starting','paired')),
|
||||
ADD CONSTRAINT gateway_identity_pairing_start_state_check CHECK (
|
||||
(state='starting' AND revision_id IS NULL) OR
|
||||
(state='paired' AND revision_id IS NOT NULL)
|
||||
);
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conrelid='gateway_identity_pairing_start_reservation'::regclass
|
||||
AND contype='f'
|
||||
AND pg_get_constraintdef(oid) LIKE 'FOREIGN KEY (revision_id)%'
|
||||
) THEN
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
ADD CONSTRAINT gateway_identity_pairing_start_revision_fk
|
||||
FOREIGN KEY (revision_id)
|
||||
REFERENCES gateway_identity_configuration_revisions(id)
|
||||
ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_identity_pairing_start_revision_unique
|
||||
ON gateway_identity_pairing_start_reservation(revision_id)
|
||||
WHERE revision_id IS NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_gateway_identity_pairing_start_expiry;
|
||||
|
||||
CREATE INDEX idx_gateway_identity_pairing_start_expiry
|
||||
ON gateway_identity_pairing_start_reservation(state,expires_at);
|
||||
@@ -0,0 +1,32 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
-- Pairing recovery added explicit, redacted error categories after 0069 was
|
||||
-- released. Keep the database allow-list in lockstep so PostgreSQL can persist
|
||||
-- the reason an administrator must resolve instead of hiding it behind a CHECK
|
||||
-- violation.
|
||||
ALTER TABLE gateway_identity_onboarding_exchanges
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_onboarding_error_category_check;
|
||||
|
||||
ALTER TABLE gateway_identity_onboarding_exchanges
|
||||
ADD CONSTRAINT gateway_identity_onboarding_error_category_check
|
||||
CHECK (last_error_category IS NULL OR last_error_category IN (
|
||||
'metadata_submission_failed','exchange_status_unavailable','credential_delivery_failed',
|
||||
'exchange_completion_failed','exchange_expired','remote_exchange_failed','remote_state_invalid',
|
||||
'security_event_configuration_invalid','security_event_connection_conflict',
|
||||
'security_event_discovery_failed','security_event_management_token_failed',
|
||||
'security_event_stream_create_failed','security_event_stream_response_invalid',
|
||||
'security_event_receiver_activation_failed','security_event_preparation_failed',
|
||||
'security_event_retirement_pending','security_event_credential_handoff_unsafe',
|
||||
'security_event_connection_binding_missing','security_event_connection_binding_unavailable',
|
||||
'security_event_connection_binding_invalid','security_event_connection_binding_mismatch',
|
||||
'pairing_step_failed',
|
||||
'cleanup_revision_unavailable','cleanup_security_event_unavailable',
|
||||
'cleanup_security_event_configuration_invalid','cleanup_security_event_connection_conflict',
|
||||
'cleanup_security_event_discovery_failed','cleanup_security_event_management_token_failed',
|
||||
'cleanup_security_event_stream_create_failed','cleanup_security_event_stream_response_invalid',
|
||||
'cleanup_security_event_receiver_activation_failed','cleanup_security_event_preparation_failed',
|
||||
'cleanup_security_event_retirement_pending','cleanup_security_event_connection_cleanup_failed',
|
||||
'cleanup_security_event_secret_cleanup_failed','cleanup_security_event_credential_handoff_unsafe',
|
||||
'cleanup_security_event_failed','cleanup_secret_store_failed','cleanup_finalize_failed'
|
||||
));
|
||||
Reference in New Issue
Block a user