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:
chengcheng 2026-07-17 18:31:12 +08:00
parent cdfca61304
commit a312ad880d
55 changed files with 9227 additions and 421 deletions

View File

@ -0,0 +1,254 @@
package main
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
const identityPairingStartReservationUpgradeMigration = "../../migrations/0073_identity_pairing_start_reservation_upgrade.sql"
func TestIdentityPairingStartReservationUpgradeMigrationDefinesCurrentLifecycle(t *testing.T) {
payload, err := os.ReadFile(identityPairingStartReservationUpgradeMigration)
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"add column if not exists state text",
"add column if not exists revision_id uuid",
"add column if not exists updated_at timestamptz",
"alter column state set not null",
"gateway_identity_pairing_start_state_check",
"foreign key (revision_id)",
"idx_gateway_identity_pairing_start_revision_unique",
"idx_gateway_identity_pairing_start_expiry",
"canonical_pairing",
"on conflict (singleton) do update",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity pairing start reservation upgrade migration is missing %q", required)
}
}
for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret", "secret_value"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity pairing start reservation upgrade migration stores forbidden value field %q", forbidden)
}
}
}
func TestIdentityPairingStartReservationUpgradeMigratesLegacyShapeAndIsIdempotent(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
applyIdentityPairingStartReservationPrerequisites(t, ctx, pool)
revisionID, pairingID, pairingExpiresAt := seedOutstandingIdentityPairingForReservationMigration(t, ctx, pool)
legacyAttemptID := uuid.NewString()
if _, err := pool.Exec(ctx, `
CREATE TABLE gateway_identity_pairing_start_reservation (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
attempt_id uuid NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("create legacy pairing start reservation: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,expires_at)
VALUES(true,$1::uuid,now()+interval '2 minutes')`, legacyAttemptID); err != nil {
t.Fatalf("seed legacy pairing start reservation: %v", err)
}
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt)
assertIdentityPairingStartReservationSchema(t, ctx, pool)
// Migration runners apply each version once, but a repeat execution proves
// that recovery from an interrupted/manual rollout does not duplicate state.
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt)
assertIdentityPairingStartReservationSchema(t, ctx, pool)
}
func TestIdentityPairingStartReservationUpgradePreservesLegacyStartingLease(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
applyIdentityPairingStartReservationPrerequisites(t, ctx, pool)
attemptID := uuid.NewString()
expiresAt := time.Now().UTC().Add(2 * time.Minute).Truncate(time.Microsecond)
if _, err := pool.Exec(ctx, `
CREATE TABLE gateway_identity_pairing_start_reservation (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
attempt_id uuid NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("create legacy pairing start reservation: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,expires_at)
VALUES(true,$1::uuid,$2)`, attemptID, expiresAt); err != nil {
t.Fatalf("seed legacy starting reservation: %v", err)
}
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
var gotAttemptID, state string
var revisionID *string
var gotExpiresAt, updatedAt time.Time
if err := pool.QueryRow(ctx, `
SELECT attempt_id::text,state,revision_id::text,expires_at,updated_at
FROM gateway_identity_pairing_start_reservation`).
Scan(&gotAttemptID, &state, &revisionID, &gotExpiresAt, &updatedAt); err != nil {
t.Fatalf("read upgraded legacy starting reservation: %v", err)
}
if gotAttemptID != attemptID || state != "starting" || revisionID != nil ||
!gotExpiresAt.Equal(expiresAt) || updatedAt.IsZero() {
t.Fatalf("unexpected upgraded starting reservation attempt=%q state=%q revision=%v expires=%v updated=%v",
gotAttemptID, state, revisionID, gotExpiresAt, updatedAt)
}
_, err := pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='paired'`)
requireIdentityPairingStartReservationMigrationCode(t, err, "23514", "paired reservation without revision")
}
func TestIdentityPairingStartReservationUpgradeAcceptsFresh0071Schema(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
applyIdentityPairingStartReservationPrerequisites(t, ctx, pool)
revisionID, pairingID, pairingExpiresAt := seedOutstandingIdentityPairingForReservationMigration(t, ctx, pool)
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0071_identity_pairing_start_reservation.sql")
assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt)
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration)
assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt)
assertIdentityPairingStartReservationSchema(t, ctx, pool)
}
func applyIdentityPairingStartReservationPrerequisites(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
for _, migration := range []string{
"../../migrations/0067_identity_configuration_revisions.sql",
"../../migrations/0068_identity_onboarding_exchanges.sql",
"../../migrations/0069_identity_pairing_cancellation.sql",
} {
applyIdentityMigrationTestFile(t, ctx, pool, migration)
}
}
func seedOutstandingIdentityPairingForReservationMigration(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
) (string, string, time.Time) {
t.Helper()
revisionID := uuid.NewString()
pairingID := uuid.NewString()
expiresAt := time.Now().UTC().Add(30 * time.Minute).Truncate(time.Microsecond)
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
t.Fatalf("seed identity revision for pairing reservation migration: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,$5)`,
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID, expiresAt); err != nil {
t.Fatalf("seed identity exchange for pairing reservation migration: %v", err)
}
return revisionID, pairingID, expiresAt
}
func assertCurrentIdentityPairingStartReservation(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
wantAttemptID string,
wantRevisionID string,
wantExpiresAt time.Time,
) {
t.Helper()
var count int
var attemptID, state, revisionID string
var expiresAt, updatedAt time.Time
if err := pool.QueryRow(ctx, `
SELECT count(*) OVER (),attempt_id::text,state,revision_id::text,expires_at,updated_at
FROM gateway_identity_pairing_start_reservation`).
Scan(&count, &attemptID, &state, &revisionID, &expiresAt, &updatedAt); err != nil {
t.Fatalf("read upgraded pairing start reservation: %v", err)
}
if count != 1 || attemptID != wantAttemptID || state != "paired" || revisionID != wantRevisionID ||
!expiresAt.Equal(wantExpiresAt) || updatedAt.IsZero() {
t.Fatalf("unexpected upgraded pairing reservation count=%d attempt=%q state=%q revision=%q expires=%v updated=%v",
count, attemptID, state, revisionID, expiresAt, updatedAt)
}
}
func assertIdentityPairingStartReservationSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
for _, column := range []struct {
name string
wantDefaultFragment string
}{
{name: "state", wantDefaultFragment: "starting"},
{name: "updated_at", wantDefaultFragment: "now()"},
} {
var nullable, defaultExpression string
if err := pool.QueryRow(ctx, `
SELECT is_nullable,COALESCE(column_default,'')
FROM information_schema.columns
WHERE table_schema=current_schema()
AND table_name='gateway_identity_pairing_start_reservation'
AND column_name=$1`, column.name).Scan(&nullable, &defaultExpression); err != nil {
t.Fatalf("read upgraded pairing reservation column %s: %v", column.name, err)
}
if nullable != "NO" || !strings.Contains(defaultExpression, column.wantDefaultFragment) {
t.Fatalf("pairing reservation column %s nullable=%q default=%q", column.name, nullable, defaultExpression)
}
}
var expiryIndex, revisionIndex string
if err := pool.QueryRow(ctx, `SELECT pg_get_indexdef('idx_gateway_identity_pairing_start_expiry'::regclass)`).
Scan(&expiryIndex); err != nil {
t.Fatalf("read pairing reservation expiry index: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT pg_get_indexdef('idx_gateway_identity_pairing_start_revision_unique'::regclass)`).
Scan(&revisionIndex); err != nil {
t.Fatalf("read pairing reservation revision index: %v", err)
}
if !strings.Contains(strings.ToLower(expiryIndex), "(state, expires_at)") ||
!strings.Contains(strings.ToLower(revisionIndex), "unique") ||
!strings.Contains(strings.ToLower(revisionIndex), "(revision_id)") {
t.Fatalf("unexpected pairing reservation indexes expiry=%q revision=%q", expiryIndex, revisionIndex)
}
_, err := pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='starting'`)
requireIdentityPairingStartReservationMigrationCode(t, err, "23514", "starting reservation with revision")
_, err = pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET revision_id=$1::uuid`, uuid.NewString())
requireIdentityPairingStartReservationMigrationCode(t, err, "23503", "reservation with unknown revision")
}
func requireIdentityPairingStartReservationMigrationCode(t *testing.T, err error, wantCode, operation string) {
t.Helper()
if err == nil {
t.Fatalf("%s unexpectedly satisfied migration constraints", operation)
}
var postgresError *pgconn.PgError
if !errors.As(err, &postgresError) || postgresError.Code != wantCode {
t.Fatalf("%s error=%v, want PostgreSQL code %s", operation, err, wantCode)
}
}

View File

@ -1,9 +1,17 @@
package main
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSecurityEventIdempotencyRepairMigrationExists(t *testing.T) {
@ -85,3 +93,276 @@ func TestIdentityOnboardingExchangeMigrationStoresOnlySecretReferences(t *testin
}
}
}
func TestIdentityPairingCancellationMigrationSeparatesCleanupLifecycle(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0069_identity_pairing_cancellation.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"'cancelled'",
"cleanup_status text not null default 'none'",
"cleanup_completed_at",
"idx_gateway_identity_onboarding_cleanup",
"last_error_category",
"set last_error_category = 'pairing_step_failed'",
"set local lock_timeout = '10s'",
"set local statement_timeout = '60s'",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity pairing cancellation migration is missing %q", required)
}
}
for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity pairing cancellation migration stores forbidden secret field %q", forbidden)
}
}
}
func TestIdentityPairingErrorCategoryUpgradeMigrationExists(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0074_identity_pairing_error_categories.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"drop constraint if exists gateway_identity_onboarding_error_category_check",
"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",
"cleanup_security_event_credential_handoff_unsafe",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity pairing error category migration is missing %q", required)
}
}
}
func TestIdentityPairingErrorCategoryUpgradeMigrationExecutesAgainstCurrentSchema(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
for _, migration := range []string{
"../../migrations/0067_identity_configuration_revisions.sql",
"../../migrations/0068_identity_onboarding_exchanges.sql",
"../../migrations/0069_identity_pairing_cancellation.sql",
"../../migrations/0070_identity_secret_cleanup_queue.sql",
"../../migrations/0071_identity_pairing_start_reservation.sql",
"../../migrations/0072_identity_secret_cleanup_claim_lifecycle.sql",
"../../migrations/0073_identity_pairing_start_reservation_upgrade.sql",
"../../migrations/0074_identity_pairing_error_categories.sql",
} {
applyIdentityMigrationTestFile(t, ctx, pool, migration)
}
revisionID, pairingID := uuid.NewString(), uuid.NewString()
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
t.Fatalf("seed identity revision: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes')`,
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
t.Fatalf("seed onboarding exchange: %v", err)
}
for _, category := range []string{
"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",
"cleanup_security_event_credential_handoff_unsafe",
} {
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET last_error_category=$2 WHERE id=$1::uuid`, pairingID, category); err != nil {
t.Fatalf("persist supported error category %q: %v", category, err)
}
}
_, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET last_error_category='credential_secret_leaked' WHERE id=$1::uuid`, pairingID)
requireIdentityMigrationCheckViolation(t, err, "unknown upgraded identity pairing category")
}
func TestIdentitySecretCleanupQueueMigrationStoresOnlyReferences(t *testing.T) {
payload, err := os.ReadFile("../../migrations/0070_identity_secret_cleanup_queue.sql")
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"create table if not exists gateway_identity_secret_cleanup_queue",
"secret_ref text primary key",
"not_before timestamptz not null",
"status text not null default 'pending'",
"claim_token uuid",
"lease_expires_at timestamptz",
"gateway_identity_secret_cleanup_claim_check",
"idx_gateway_identity_secret_cleanup_due",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity Secret cleanup migration is missing %q", required)
}
}
for _, forbidden := range []string{"secret_value", "client_secret", "machine_secret", "token text"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity Secret cleanup migration stores forbidden value field %q", forbidden)
}
}
}
func TestIdentityPairingCancellationMigrationExecutesAgainstLegacyData(t *testing.T) {
pool := newIdentityMigrationPostgresTestSchema(t)
ctx := context.Background()
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0067_identity_configuration_revisions.sql")
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0068_identity_onboarding_exchanges.sql")
revisionID, pairingID := uuid.NewString(), uuid.NewString()
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
t.Fatalf("seed legacy identity revision: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at,last_error_category
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes','secret_token_abcdef')`,
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
t.Fatalf("seed legacy onboarding exchange: %v", err)
}
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0069_identity_pairing_cancellation.sql")
var status, cleanupStatus, errorCategory string
var cancelledAt, cleanupCompletedAt *time.Time
if err := pool.QueryRow(ctx, `
SELECT status,cleanup_status,last_error_category,cancelled_at,cleanup_completed_at
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID).
Scan(&status, &cleanupStatus, &errorCategory, &cancelledAt, &cleanupCompletedAt); err != nil {
t.Fatalf("read migrated onboarding exchange: %v", err)
}
if status != "credentials_saved" || cleanupStatus != "none" || errorCategory != "pairing_step_failed" ||
cancelledAt != nil || cleanupCompletedAt != nil {
t.Fatalf("unexpected migrated exchange status=%q cleanup=%q error=%q cancelled=%v completed=%v",
status, cleanupStatus, errorCategory, cancelledAt, cleanupCompletedAt)
}
_, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges SET status='cancelled' WHERE id=$1::uuid`, pairingID)
requireIdentityMigrationCheckViolation(t, err, "cancelled status without cleanup intent")
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET status='cancelled',cleanup_status='pending',cancelled_at=now()
WHERE id=$1::uuid`, pairingID); err != nil {
t.Fatalf("persist valid pending cleanup lifecycle: %v", err)
}
_, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET cleanup_status='completed' WHERE id=$1::uuid`, pairingID)
requireIdentityMigrationCheckViolation(t, err, "completed cleanup without completion timestamp")
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET cleanup_status='completed',cleanup_completed_at=now()
WHERE id=$1::uuid`, pairingID); err != nil {
t.Fatalf("persist valid completed cleanup lifecycle: %v", err)
}
_, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET last_error_category='still-invalid' WHERE id=$1::uuid`, pairingID)
requireIdentityMigrationCheckViolation(t, err, "invalid post-migration error category")
if err := pool.QueryRow(ctx, `SELECT status,cleanup_status FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID).
Scan(&status, &cleanupStatus); err != nil {
t.Fatalf("read completed cleanup lifecycle: %v", err)
}
if status != "cancelled" || cleanupStatus != "completed" {
t.Fatalf("completed cleanup lifecycle status=%q cleanup=%q", status, cleanupStatus)
}
}
func applyIdentityMigrationTestFile(t *testing.T, ctx context.Context, pool *pgxpool.Pool, path string) {
t.Helper()
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read migration %s: %v", path, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin migration %s: %v", path, err)
}
if _, err := tx.Exec(ctx, string(payload)); err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("execute migration %s: %v", path, err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit migration %s: %v", path, err)
}
}
func requireIdentityMigrationCheckViolation(t *testing.T, err error, operation string) {
t.Helper()
if err == nil {
t.Fatalf("%s unexpectedly satisfied migration constraints", operation)
}
var postgresError *pgconn.PgError
if !errors.As(err, &postgresError) || postgresError.Code != "23514" {
t.Fatalf("%s error=%v, want PostgreSQL check violation", operation, err)
}
}
func newIdentityMigrationPostgresTestSchema(t *testing.T) *pgxpool.Pool {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity migration PostgreSQL integration tests")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect identity migration test database: %v", err)
}
var databaseName string
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
admin.Close()
t.Fatalf("read identity migration test database name: %v", err)
}
if !strings.Contains(strings.ToLower(databaseName), "test") {
admin.Close()
t.Fatalf("refusing to use non-test database %q", databaseName)
}
schemaName := "gateway_identity_migration_" + strings.ReplaceAll(uuid.NewString(), "-", "")
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
admin.Close()
t.Fatalf("create identity migration test schema: %v", err)
}
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("parse identity migration test database URL: %v", err)
}
config.ConnConfig.RuntimeParams["search_path"] = schemaName
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("connect identity migration test schema: %v", err)
}
t.Cleanup(func() {
pool.Close()
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
t.Errorf("drop identity migration test schema: %v", err)
}
admin.Close()
})
return pool
}

View File

@ -2918,7 +2918,7 @@
"BearerAuth": []
}
],
"description": "保留 Superseded Revision 供回滚,清理 BFF Session并继续允许本地管理登录。",
"description": "将 Revision 转为只读 Superseded 审计历史,清理 BFF Session并继续允许本地管理登录;重新接入必须使用新的接入码。",
"produces": [
"application/json"
],
@ -3116,6 +3116,174 @@
}
}
},
"/api/admin/system/identity/pairings/{pairingID}/cancel": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "原子封存未激活 Revision并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 Exchange下一次凭据交付会轮换机器凭据。",
"produces": [
"application/json"
],
"tags": [
"identity"
],
"summary": "放弃本地统一认证配对",
"parameters": [
{
"type": "string",
"description": "配对 ID",
"name": "pairingID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "幂等键",
"name": "Idempotency-Key",
"in": "header",
"required": true
},
{
"type": "string",
"description": "当前 Pairing ETag",
"name": "If-Match",
"in": "header",
"required": true
}
],
"responses": {
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/identity.PairingExchange"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"412": {
"description": "Precondition Failed",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"428": {
"description": "Precondition Required",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。",
"produces": [
"application/json"
],
"tags": [
"identity"
],
"summary": "安全退役阻塞配对的旧 SSF 连接",
"parameters": [
{
"type": "string",
"description": "配对 ID",
"name": "pairingID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "幂等键",
"name": "Idempotency-Key",
"in": "header",
"required": true
},
{
"type": "string",
"description": "当前 Pairing ETag",
"name": "If-Match",
"in": "header",
"required": true
}
],
"responses": {
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/identity.PairingExchange"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"412": {
"description": "Precondition Failed",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"428": {
"description": "Precondition Required",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/admin/system/identity/revisions/{revisionID}": {
"patch": {
"security": [
@ -3290,13 +3458,14 @@
"BearerAuth": []
}
],
"description": "当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。",
"produces": [
"application/json"
],
"tags": [
"identity"
],
"summary": "回滚统一认证 Revision",
"summary": "请求恢复历史统一认证 Revision",
"parameters": [
{
"type": "string",
@ -4792,7 +4961,7 @@
},
"/api/v1/auth/login": {
"post": {
"description": "使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。",
"description": "使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。",
"consumes": [
"application/json"
],
@ -11492,12 +11661,34 @@
}
}
},
"identity.PairingCleanupStatus": {
"type": "string",
"enum": [
"none",
"pending",
"completed"
],
"x-enum-varnames": [
"PairingCleanupNone",
"PairingCleanupPending",
"PairingCleanupCompleted"
]
},
"identity.PairingExchange": {
"type": "object",
"properties": {
"authCenterAuditId": {
"type": "string"
},
"cancelledAt": {
"type": "string"
},
"cleanupCompletedAt": {
"type": "string"
},
"cleanupStatus": {
"$ref": "#/definitions/identity.PairingCleanupStatus"
},
"createdAt": {
"type": "string"
},
@ -11565,7 +11756,8 @@
"credentials_saved",
"completed",
"failed",
"expired"
"expired",
"cancelled"
],
"x-enum-varnames": [
"PairingMetadataPending",
@ -11574,7 +11766,8 @@
"PairingCredentialsSaved",
"PairingCompleted",
"PairingFailed",
"PairingExpired"
"PairingExpired",
"PairingCancelled"
]
},
"identity.Revision": {

View File

@ -932,10 +932,26 @@ definitions:
example: manual recharge
type: string
type: object
identity.PairingCleanupStatus:
enum:
- none
- pending
- completed
type: string
x-enum-varnames:
- PairingCleanupNone
- PairingCleanupPending
- PairingCleanupCompleted
identity.PairingExchange:
properties:
authCenterAuditId:
type: string
cancelledAt:
type: string
cleanupCompletedAt:
type: string
cleanupStatus:
$ref: '#/definitions/identity.PairingCleanupStatus'
createdAt:
type: string
expiresAt:
@ -983,6 +999,7 @@ definitions:
- completed
- failed
- expired
- cancelled
type: string
x-enum-varnames:
- PairingMetadataPending
@ -992,6 +1009,7 @@ definitions:
- PairingCompleted
- PairingFailed
- PairingExpired
- PairingCancelled
identity.Revision:
properties:
activatedAt:
@ -4616,7 +4634,7 @@ paths:
- identity
/api/admin/system/identity/disable:
post:
description: 保留 Superseded Revision 供回滚,清理 BFF Session并继续允许本地管理登录。
description: 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session并继续允许本地管理登录;重新接入必须使用新的接入码
parameters:
- description: 幂等键
in: header
@ -4746,6 +4764,118 @@ paths:
summary: 查询统一认证配对状态
tags:
- identity
/api/admin/system/identity/pairings/{pairingID}/cancel:
post:
description: 原子封存未激活 Revision并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端
Exchange下一次凭据交付会轮换机器凭据。
parameters:
- description: 配对 ID
in: path
name: pairingID
required: true
type: string
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Pairing ETag
in: header
name: If-Match
required: true
type: string
produces:
- application/json
responses:
"202":
description: Accepted
schema:
$ref: '#/definitions/identity.PairingExchange'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"409":
description: Conflict
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 放弃本地统一认证配对
tags:
- identity
/api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event:
post:
description: 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision
的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。
parameters:
- description: 配对 ID
in: path
name: pairingID
required: true
type: string
- description: 幂等键
in: header
name: Idempotency-Key
required: true
type: string
- description: 当前 Pairing ETag
in: header
name: If-Match
required: true
type: string
produces:
- application/json
responses:
"202":
description: Accepted
schema:
$ref: '#/definitions/identity.PairingExchange'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"409":
description: Conflict
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"412":
description: Precondition Failed
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"428":
description: Precondition Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 安全退役阻塞配对的旧 SSF 连接
tags:
- identity
/api/admin/system/identity/revisions/{revisionID}:
patch:
consumes:
@ -4857,6 +4987,7 @@ paths:
- identity
/api/admin/system/identity/revisions/{revisionID}/rollback:
post:
description: 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。
parameters:
- description: Revision ID
in: path
@ -4902,7 +5033,7 @@ paths:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 回滚统一认证 Revision
summary: 请求恢复历史统一认证 Revision
tags:
- identity
/api/admin/system/identity/revisions/{revisionID}/validate:
@ -5825,7 +5956,7 @@ paths:
post:
consumes:
- application/json
description: 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。
description: 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。
parameters:
- description: 登录请求account 可为用户名或邮箱
in: body

View File

@ -22,7 +22,9 @@ import (
type Permission string
const (
OIDCSessionCookieName = "easyai_gateway_oidc_session"
OIDCSessionCookieName = "easyai_gateway_oidc_session"
localBreakGlassTokenPurpose = "local_break_glass_manager"
legacyAccessTokenPurpose = "legacy_access"
PermissionPublic Permission = "public"
PermissionBasic Permission = "basic"
@ -52,6 +54,7 @@ type User struct {
TokenExpiresAt time.Time `json:"-"`
TokenIssuedAt time.Time `json:"-"`
Issuer string `json:"-"`
TokenPurpose string `json:"-"`
}
type contextKey string
@ -142,15 +145,24 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H
}
func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
token := extractBearer(r.Header.Get("Authorization"))
if token == "" {
token = strings.TrimSpace(r.Header.Get("x-comfy-api-key"))
}
if token == "" {
token = strings.TrimSpace(r.Header.Get("x-goog-api-key"))
}
if token == "" {
token = strings.TrimSpace(r.URL.Query().Get("key"))
var token string
if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" {
token = extractBearer(authorization)
if token == "" {
return nil, ErrUnauthorized
}
} else if value := strings.TrimSpace(r.Header.Get("x-comfy-api-key")); value != "" {
token = value
} else if value := strings.TrimSpace(r.Header.Get("x-goog-api-key")); value != "" {
token = value
} else if value := strings.TrimSpace(r.URL.Query().Get("key")); value != "" {
// Query credentials are retained only for API compatibility with
// providers that use `?key=sk-*`. Bearer/OIDC tokens in URLs would leak
// through browser history, reverse-proxy logs, and diagnostics.
if !strings.HasPrefix(value, "sk-") {
return nil, ErrUnauthorized
}
token = value
}
if token == "" {
if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
@ -175,10 +187,14 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
if algorithm == "RS256" || algorithm == "ES256" {
return a.AuthenticateOIDCAccessToken(r.Context(), token)
}
if !a.legacyJWTEnabled() {
return nil, ErrUnauthorized
user, err := a.verifyJWT(token)
if err != nil {
return nil, err
}
return a.verifyJWT(token)
if a.legacyJWTEnabled() || isLocalBreakGlassManager(user) {
return user, nil
}
return nil, ErrUnauthorized
}
func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) {
@ -233,6 +249,7 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
APIKeyName: stringClaim(claims, "apiKeyName"),
APIKeyPrefix: stringClaim(claims, "apiKeyPrefix"),
APIKeyScopes: stringSliceClaim(claims, "apiKeyScopes"),
TokenPurpose: stringClaim(claims, "tokenPurpose"),
}
if user.Source == "" {
user.Source = "gateway"
@ -248,6 +265,10 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
ttl = time.Hour
}
now := time.Now()
tokenPurpose := legacyAccessTokenPurpose
if user.Source == "gateway" && hasManagerRole(user.Roles) {
tokenPurpose = localBreakGlassTokenPurpose
}
claims := jwt.MapClaims{
"sub": user.ID,
"username": user.Username,
@ -264,6 +285,7 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
"apiKeyName": user.APIKeyName,
"apiKeyPrefix": user.APIKeyPrefix,
"apiKeyScopes": user.APIKeyScopes,
"tokenPurpose": tokenPurpose,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
}
@ -271,6 +293,19 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
return token.SignedString([]byte(a.JWTSecret))
}
func isLocalBreakGlassManager(user *User) bool {
return user != nil && user.Source == "gateway" && user.TokenPurpose == localBreakGlassTokenPurpose && hasManagerRole(user.Roles)
}
func hasManagerRole(roles []string) bool {
for _, role := range roles {
if role == "manager" || role == "admin" {
return true
}
}
return false
}
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
if a.LocalAPIKeyVerifier != nil {
user, err := a.LocalAPIKeyVerifier(ctx, apiKey)

View File

@ -12,6 +12,7 @@ import (
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/url"
"strings"
@ -25,6 +26,7 @@ const maxOIDCResponseBytes = 1 << 20
const defaultOIDCHTTPTimeout = 10 * time.Second
type OIDCConfig struct {
AppEnv string
Issuer string
Audience string
TenantID string
@ -90,7 +92,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
config.Audience = strings.TrimSpace(config.Audience)
config.TenantID = strings.TrimSpace(config.TenantID)
config.RolePrefix = strings.TrimSpace(config.RolePrefix)
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
return nil, errors.New("issuer, audience, tenant and role prefix are required")
}
if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil &&
@ -262,10 +264,10 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
if err := v.fetchJSON(ctx, discoveryURL, &discovery); err != nil {
return err
}
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil {
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI, v.config.AppEnv) != nil {
return errors.New("OIDC discovery metadata is invalid")
}
if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint, v.config.AppEnv) != nil {
return errors.New("OIDC introspection metadata is invalid")
}
var set jsonWebKeySet
@ -405,7 +407,7 @@ func decodeBigInt(value string) (*big.Int, error) {
return new(big.Int).SetBytes(payload), nil
}
func validatePublicURL(raw string) error {
func validatePublicURL(raw, appEnv string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("invalid URL")
@ -413,12 +415,30 @@ func validatePublicURL(raw string) error {
if parsed.Scheme == "https" {
return nil
}
if parsed.Scheme == "http" && (parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "localhost") {
if parsed.Scheme == "http" && isLocalOIDCEnvironment(appEnv) && isLoopbackOIDCHost(parsed.Hostname()) {
return nil
}
return errors.New("URL must use HTTPS")
}
func isLocalOIDCEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
return true
default:
return false
}
}
func isLoopbackOIDCHost(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "localhost" {
return true
}
ip := net.ParseIP(value)
return ip != nil && ip.IsLoopback()
}
func numericDateClaim(value any) (time.Time, bool) {
switch typed := value.(type) {
case float64:

View File

@ -19,6 +19,7 @@ var ErrOIDCInvalidGrant = errors.New("OIDC refresh token is invalid")
var errOIDCResponseTooLarge = errors.New("OIDC response exceeds size limit")
type OIDCPublicClientConfig struct {
AppEnv string
Issuer string
ClientID string
RedirectURI string
@ -64,7 +65,7 @@ func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, erro
return nil, errors.New("offline_access is not allowed for Gateway browser sessions")
}
}
if validatePublicURL(config.Issuer) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI) != nil || validatePublicURL(config.PostLogoutRedirectURI) != nil {
if validatePublicURL(config.Issuer, config.AppEnv) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI, config.AppEnv) != nil || validatePublicURL(config.PostLogoutRedirectURI, config.AppEnv) != nil {
return nil, errors.New("issuer, public client id and exact redirect URLs are required")
}
return &OIDCPublicClient{config: config, client: newOIDCHTTPClient(config.HTTPClient)}, nil
@ -220,9 +221,9 @@ func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, o
}
var metadata oidcClientDiscovery
if err := provider.Claims(&metadata); err != nil || metadata.Issuer != c.config.Issuer ||
validatePublicURL(metadata.AuthorizationEndpoint) != nil || validatePublicURL(metadata.TokenEndpoint) != nil || validatePublicURL(metadata.JWKSURI) != nil ||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint) != nil ||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint) != nil {
validatePublicURL(metadata.AuthorizationEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.TokenEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.JWKSURI, c.config.AppEnv) != nil ||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint, c.config.AppEnv) != nil ||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint, c.config.AppEnv) != nil {
return nil, oidcClientDiscovery{}, errors.New("OIDC discovery metadata is invalid")
}
endpoint := provider.Endpoint()

View File

@ -57,6 +57,7 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", Scopes: []string{"openid", "profile", "gateway.access"}, HTTPClient: server.Client(),
})
@ -84,6 +85,7 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) {
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "production",
Issuer: "https://auth.example.com", ClientID: "gateway-public",
RedirectURI: "https://gateway.example.com/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: "https://gateway.example.com/",
@ -119,6 +121,7 @@ func TestOIDCPublicClientVerifiesIDTokenNonceAndAudience(t *testing.T) {
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public-client", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
@ -195,6 +198,7 @@ func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
defer server.Close()
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
@ -234,6 +238,7 @@ func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testi
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
@ -245,3 +250,43 @@ func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testi
t.Fatalf("invalid_grant mapping was not stable and redacted: %v", err)
}
}
func TestOIDCPublicClientRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
var issuer, insecureField string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
metadata := map[string]any{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
"id_token_signing_alg_values_supported": []string{"RS256", "ES256"},
}
metadata[insecureField] = "http://127.0.0.1:1/oidc-endpoint"
_ = json.NewEncoder(w).Encode(metadata)
}))
defer server.Close()
issuer = server.URL
for _, field := range []string{
"authorization_endpoint", "token_endpoint", "jwks_uri", "revocation_endpoint", "end_session_endpoint",
} {
t.Run(field, func(t *testing.T) {
insecureField = field
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "production", Issuer: issuer, ClientID: "gateway-public",
RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/",
HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := client.ValidateConfiguration(context.Background()); err == nil {
t.Fatalf("production accepted loopback HTTP %s", field)
}
})
}
}

View File

@ -2,10 +2,13 @@ package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestAuthenticateResolvesOpaqueOIDCSessionCookie(t *testing.T) {
@ -49,6 +52,47 @@ func TestAuthenticateBearerTakesPrecedenceOverOIDCSessionCookie(t *testing.T) {
}
}
func TestAuthenticateRejectsMalformedExplicitCredentialInsteadOfFallingBackToCookie(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
var resolved bool
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
resolved = true
return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil
}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
request.Header.Set("Authorization", "not-a-bearer-credential")
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"})
if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("malformed explicit credential error=%v, want unauthorized", err)
}
if resolved {
t.Fatal("malformed Authorization header fell back to the OIDC session cookie")
}
}
func TestAuthenticateRejectsManagerJWTInQueryWithoutCookieFallback(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
if err != nil {
t.Fatal(err)
}
var resolved bool
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
resolved = true
return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil
}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil)
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"})
if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("manager query JWT error=%v, want unauthorized", err)
}
if resolved {
t.Fatal("rejected query credential fell back to the OIDC session cookie")
}
}
func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
@ -81,6 +125,46 @@ func TestAuthenticatorReadsOIDCSessionResolverAndLegacyPolicyDynamically(t *test
}
}
func TestAuthenticateKeepsOnlySignedBreakGlassManagerWhenLegacyJWTDisabled(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
authenticator.LegacyJWTEnabledProvider = func() bool { return false }
managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
if err != nil {
t.Fatal(err)
}
managerRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil)
managerRequest.Header.Set("Authorization", "Bearer "+managerToken)
manager, err := authenticator.Authenticate(managerRequest)
if err != nil || manager.ID != "manager" {
t.Fatalf("signed break-glass manager was rejected: user=%#v err=%v", manager, err)
}
userToken, err := authenticator.SignJWT(&User{ID: "user", Source: "gateway", Roles: []string{"user"}}, time.Hour)
if err != nil {
t.Fatal(err)
}
userRequest := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
userRequest.Header.Set("Authorization", "Bearer "+userToken)
if _, err := authenticator.Authenticate(userRequest); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("ordinary local JWT error = %v, want unauthorized", err)
}
legacyManager := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "legacy-manager", "source": "gateway", "role": []string{"manager"},
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
})
legacyManagerToken, err := legacyManager.SignedString([]byte(authenticator.JWTSecret))
if err != nil {
t.Fatal(err)
}
legacyRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil)
legacyRequest.Header.Set("Authorization", "Bearer "+legacyManagerToken)
if _, err := authenticator.Authenticate(legacyRequest); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("legacy manager without token purpose error = %v, want unauthorized", err)
}
}
func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {

View File

@ -43,6 +43,7 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) {
defer server.Close()
issuer = server.URL
verifier, err := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1",
RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
})
@ -85,6 +86,7 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
defer server.Close()
issuer = server.URL
verifier, _ := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
})
@ -139,6 +141,7 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
defer server.Close()
issuer = server.URL
verifier, err := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true,
@ -182,6 +185,7 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
issuer = server.URL
evaluation := OIDCSecurityEventEvaluation{RequireIntrospection: true}
verifier, err := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
IntrospectionEnabled: true,
@ -227,6 +231,72 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes
}
}
func TestOIDCVerifierRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
var issuer, jwksURI, introspectionEndpoint string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch request.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "jwks_uri": jwksURI, "introspection_endpoint": introspectionEndpoint,
})
case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{rsaJWK("rsa-key", &key.PublicKey)}})
default:
http.NotFound(w, request)
}
}))
defer server.Close()
issuer = server.URL
for _, test := range []struct {
name string
jwks string
introspection string
}{
{name: "JWKS", jwks: "http://127.0.0.1:1/jwks"},
{name: "introspection", jwks: issuer + "/jwks", introspection: "http://localhost:1/introspect"},
} {
t.Run(test.name, func(t *testing.T) {
jwksURI, introspectionEndpoint = test.jwks, test.introspection
config := OIDCConfig{
AppEnv: "production", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
HTTPClient: server.Client(),
}
if test.introspection != "" {
config.IntrospectionEnabled = true
config.IntrospectionCredentialProvider = func(context.Context) (string, []byte, error) {
return "gateway-machine", []byte("machine-secret-long-enough"), nil
}
}
verifier, createErr := NewOIDCVerifier(config)
if createErr != nil {
t.Fatal(createErr)
}
if validateErr := verifier.ValidateConfiguration(context.Background()); validateErr == nil {
t.Fatalf("production accepted loopback HTTP %s endpoint", test.name)
}
})
}
}
func TestOIDCURLPolicyAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
for _, appEnv := range []string{"", "production", "staging"} {
if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err == nil {
t.Fatalf("%s accepted loopback HTTP OIDC URL", appEnv)
}
}
for _, appEnv := range []string{"local", "development", "dev", "test"} {
if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err != nil {
t.Fatalf("%s rejected loopback HTTP OIDC URL: %v", appEnv, err)
}
}
}
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
t.Helper()
now := time.Now()

View File

@ -73,6 +73,12 @@ func TestCoreLocalFlow(t *testing.T) {
if registerResponse.AccessToken == "" {
t.Fatal("register did not return access token")
}
ordinaryUsername := "smoke_user_" + suffixText
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
"username": ordinaryUsername,
"email": ordinaryUsername + "@example.com",
"password": password,
}, http.StatusCreated, &struct{}{})
var duplicateResponse map[string]any
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
@ -129,6 +135,25 @@ func TestCoreLocalFlow(t *testing.T) {
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
t.Fatalf("promote smoke user: %v", err)
}
serverMainCtx, cancelServerMain := context.WithCancel(ctx)
serverMain := httptest.NewServer(NewServerWithContext(serverMainCtx, config.Config{
AppEnv: "test", HTTPAddr: ":0", DatabaseURL: databaseURL, IdentityMode: "server-main",
JWTSecret: "test-secret", CORSAllowedOrigin: "*",
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
var breakGlassLogin struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username, "password": password,
}, http.StatusOK, &breakGlassLogin)
if breakGlassLogin.AccessToken == "" {
t.Fatal("server-main break-glass manager login did not return access token")
}
doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": ordinaryUsername, "password": password,
}, http.StatusForbidden, &map[string]any{})
serverMain.Close()
cancelServerMain()
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username,
"password": password,
@ -1697,6 +1722,9 @@ func TestOriginAllowedSupportsCommaSeparatedOrigins(t *testing.T) {
if originAllowed("http://127.0.0.1:5179", allowed) {
t.Fatal("unexpected origin should not be allowed")
}
if originAllowed("https://evil.example.com", "*") {
t.Fatal("credentialed wildcard CORS origin should not be allowed")
}
}
func assertRuntimeRecoveryReleasesPendingRateReservations(t *testing.T, ctx context.Context, db *store.Store) {

View File

@ -79,7 +79,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/auth/register [post]
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
if !s.localIdentityEnabled() {
if !s.localIdentityEnabled() || !s.ordinaryLocalJWTEnabled() {
writeError(w, http.StatusForbidden, "local registration is disabled")
return
}
@ -111,7 +111,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
// login godoc
// @Summary 本地登录
// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。
// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。
// @Tags auth
// @Accept json
// @Produce json
@ -123,10 +123,6 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/auth/login [post]
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !s.localIdentityEnabled() {
writeError(w, http.StatusForbidden, "local login is disabled")
return
}
var input store.LocalLoginInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
@ -142,6 +138,10 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if !s.localLoginAllowed(user) {
writeError(w, http.StatusForbidden, "local login is disabled except for break-glass managers")
return
}
s.writeAuthResponse(w, http.StatusOK, user)
}
@ -150,6 +150,19 @@ func (s *Server) localIdentityEnabled() bool {
return mode == "" || mode == "standalone" || mode == "hybrid"
}
func (s *Server) localLoginAllowed(user store.GatewayUser) bool {
for _, role := range user.Roles {
if role == "manager" || role == "admin" {
return true
}
}
return s.localIdentityEnabled() && s.ordinaryLocalJWTEnabled()
}
func (s *Server) ordinaryLocalJWTEnabled() bool {
return s.identityRuntime == nil || s.identityRuntime.LegacyJWTEnabled()
}
func (s *Server) writeAuthResponse(w http.ResponseWriter, status int, user store.GatewayUser) {
authUser := authUserFromGatewayUser(user)
const ttl = 24 * time.Hour

View File

@ -1,6 +1,7 @@
package httpapi
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
@ -68,6 +69,11 @@ type identityWriteOperation struct {
once sync.Once
}
type identityPairingWorker struct {
cancel context.CancelFunc
done chan struct{}
}
func (operation *identityWriteOperation) close() {
if operation != nil {
operation.once.Do(operation.release)
@ -163,12 +169,15 @@ func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) {
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.start", "pending", traceID)
if !ok {
return
}
pairing, err := s.identityPairing.Start(r.Context(), input, traceID)
if err != nil {
s.writeIdentityError(w, r, "pairing.start", "", traceID, err)
return
}
auditID := s.recordIdentityConfigurationAudit(r, "pairing.start", pairing.RevisionID, "accepted", traceID, "")
pairing.AuthCenterAuditID = ""
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
s.startIdentityPairingWorker(pairing.ID)
@ -196,6 +205,95 @@ func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, pairing)
}
// cancelIdentityPairing godoc
// @Summary 放弃本地统一认证配对
// @Description 原子封存未激活 Revision并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 Exchange下一次凭据交付会轮换机器凭据。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param pairingID path string true "配对 ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Pairing ETag"
// @Success 202 {object} identity.PairingExchange
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/pairings/{pairingID}/cancel [post]
func (s *Server) cancelIdentityPairing(w http.ResponseWriter, r *http.Request) {
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
pairingID := r.PathValue("pairingID")
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.cancel", expectedVersion, struct {
PairingID string `json:"pairingId"`
}{PairingID: pairingID})
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.cancel", pairingID, traceID)
if !ok {
return
}
pairing, err := s.identityPairing.Cancel(r.Context(), pairingID, expectedVersion, traceID, auditID)
if err != nil {
s.writeIdentityError(w, r, "pairing.cancel", pairingID, traceID, err)
return
}
done := s.stopIdentityPairingWorker(pairing.ID)
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
s.startIdentityPairingCleanupWorker(pairing.ID, done)
}
// retireIdentityPairingSecurityEventConflict godoc
// @Summary 安全退役阻塞配对的旧 SSF 连接
// @Description 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。
// @Tags identity
// @Produce json
// @Security BearerAuth
// @Param pairingID path string true "配对 ID"
// @Param Idempotency-Key header string true "幂等键"
// @Param If-Match header string true "当前 Pairing ETag"
// @Success 202 {object} identity.PairingExchange
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 412 {object} ErrorEnvelope
// @Failure 428 {object} ErrorEnvelope
// @Router /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event [post]
func (s *Server) retireIdentityPairingSecurityEventConflict(w http.ResponseWriter, r *http.Request) {
expectedVersion, ok := requiredIdentityVersion(w, r)
if !ok {
return
}
pairingID := r.PathValue("pairingID")
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.retire_security_event_conflict", expectedVersion, struct {
PairingID string `json:"pairingId"`
}{PairingID: pairingID})
if !ok {
return
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.retire_security_event_conflict", pairingID, traceID)
if !ok {
return
}
pairing, err := s.identityPairing.RetireConflictingSecurityEvents(r.Context(), pairingID, expectedVersion)
if err != nil {
s.writeIdentityError(w, r, "pairing.retire_security_event_conflict", pairingID, traceID, err)
return
}
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
s.startIdentityPairingWorker(pairing.ID)
}
// updateIdentityDraftPolicy godoc
// @Summary 修改统一认证 Draft 策略
// @Tags identity
@ -238,13 +336,16 @@ func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Reques
SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds,
}
applyIdentityPolicyPatch(&policy, patch)
updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy)
if err != nil {
s.writeIdentityError(w, r, "revision.policy", revision.ID, ensureIdentityTraceID(w, r), err)
traceID := ensureIdentityTraceID(w, r)
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.policy", revision.ID, traceID)
if !ok {
return
}
updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy)
if err != nil {
s.writeIdentityError(w, r, "revision.policy", revision.ID, traceID, err)
return
}
traceID := ensureIdentityTraceID(w, r)
auditID := s.recordIdentityConfigurationAudit(r, "revision.policy", revision.ID, "success", traceID, "")
s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID)
}
@ -292,7 +393,8 @@ func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request
}
// rollbackIdentityRevision godoc
// @Summary 回滚统一认证 Revision
// @Summary 请求恢复历史统一认证 Revision
// @Description 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。
// @Tags identity
// @Produce json
// @Security BearerAuth
@ -314,7 +416,7 @@ func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request
// disableIdentityConfiguration godoc
// @Summary 禁用统一认证
// @Description 保留 Superseded Revision 供回滚,清理 BFF Session并继续允许本地管理登录。
// @Description 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session并继续允许本地管理登录;重新接入必须使用新的接入码
// @Tags identity
// @Produce json
// @Security BearerAuth
@ -338,7 +440,10 @@ func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Req
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID := s.recordIdentityConfigurationAudit(r, "revision.disable", "active", "requested", traceID, "")
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.disable", "active", traceID)
if !ok {
return
}
disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID)
if err != nil {
s.writeIdentityError(w, r, "revision.disable", "active", traceID, err)
@ -358,7 +463,10 @@ func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Reques
}
defer operation.close()
traceID := ensureIdentityTraceID(w, r)
auditID := s.recordIdentityConfigurationAudit(r, action, r.PathValue("revisionID"), "requested", traceID, "")
auditID, ok := s.requireIdentityConfigurationAudit(w, r, action, r.PathValue("revisionID"), traceID)
if !ok {
return
}
revision, err := run(expectedVersion, traceID, auditID)
if err != nil {
s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err)
@ -425,9 +533,7 @@ func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Re
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED")
return nil, false
}
encoded, _ := json.Marshal(request)
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
requestHash := fmt.Sprintf("%x", digest[:])
requestHash := identityRequestHash(operation, version, request)
s.identityManagementMu.Lock()
write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock}
if s.store == nil {
@ -468,6 +574,12 @@ func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Re
return nil, false
}
func identityRequestHash(operation string, version int64, request any) string {
encoded, _ := json.Marshal(request)
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
return fmt.Sprintf("%x", digest[:])
}
func (s *Server) completeIdentityWrite(w http.ResponseWriter, r *http.Request, operation *identityWriteOperation, status int, payload any, version int64, auditID string) {
body, _ := json.Marshal(payload)
stored, _ := json.Marshal(storedIdentityResponse{Status: status, Body: body, ETag: identityETag(version), AuditID: auditID})
@ -541,6 +653,11 @@ func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, acti
}
func identityErrorProjection(err error) (int, string, string) {
var categorized interface{ SafeErrorCategory() string }
safeCategory := ""
if errors.As(err, &categorized) {
safeCategory = categorized.SafeErrorCategory()
}
switch {
case errors.Is(err, identity.ErrRevisionNotFound):
return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND"
@ -550,6 +667,20 @@ func identityErrorProjection(err error) (int, string, string) {
return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED"
case errors.Is(err, identity.ErrLocalTenantInvalid):
return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID"
case errors.Is(err, identity.ErrPairingInProgress):
return http.StatusConflict, "请先完成或放弃当前统一认证配对", "IDENTITY_PAIRING_IN_PROGRESS"
case errors.Is(err, identity.ErrActiveConfigurationHandoffRequired):
return http.StatusConflict, "当前仍有 Active 统一认证配置;请先禁用,再使用新接入码配对", "IDENTITY_ACTIVE_CONFIGURATION_HANDOFF_REQUIRED"
case errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired):
return http.StatusConflict, "旧版本关联的远端 OAuth/SSF 资源可能已变化;请禁用后使用新接入码恢复", "IDENTITY_ROLLBACK_CONFIGURATION_HANDOFF_REQUIRED"
case errors.Is(err, identity.ErrSecurityEventRetirementPending):
return http.StatusConflict, "旧安全事件 Stream 尚未完成断开;系统会继续重试,请稍后再次禁用", "IDENTITY_SECURITY_EVENT_RETIREMENT_PENDING"
case errors.Is(err, identity.ErrPairingNotCancellable):
return http.StatusConflict, "当前统一认证配置不能放弃", "IDENTITY_PAIRING_NOT_CANCELLABLE"
case errors.Is(err, identity.ErrPairingConflictNotResolvable):
return http.StatusConflict, "当前配对已不需要退役旧安全事件连接,请刷新状态", "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"
case safeCategory == "credential_handoff_unsafe":
return http.StatusConflict, "旧安全事件连接与本次认证中心不匹配,系统已拒绝发送凭据;请先在原配置下断开旧连接", "IDENTITY_SECURITY_EVENT_HANDOFF_UNSAFE"
case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")):
return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID"
default:
@ -583,23 +714,97 @@ func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targe
return audit.ID
}
func (s *Server) requireIdentityConfigurationAudit(w http.ResponseWriter, r *http.Request, action, targetID, traceID string) (string, bool) {
auditID := s.recordIdentityConfigurationAudit(r, action, targetID, "requested", traceID, "")
if auditID == "" {
writeError(w, http.StatusServiceUnavailable, "统一认证审计暂时不可用,操作未执行", "IDENTITY_AUDIT_UNAVAILABLE")
return "", false
}
w.Header().Set("X-Audit-Id", auditID)
return auditID, true
}
func (s *Server) startIdentityPairingWorker(pairingID string) {
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
workerContext, cancel := context.WithCancel(s.ctx)
worker := &identityPairingWorker{cancel: cancel, done: make(chan struct{})}
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, worker); loaded {
cancel()
return
}
go func() {
defer s.identityPairingWorkers.Delete(pairingID)
defer func() {
s.identityPairingWorkers.CompareAndDelete(pairingID, worker)
close(worker.done)
}()
delay := time.Second
for {
pairing, err := s.identityPairing.Continue(s.ctx, pairingID)
pairing, err := s.identityPairing.Continue(workerContext, pairingID)
if err == nil {
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired {
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired || pairing.Status == identity.PairingCancelled {
return
}
delay = time.Second
} else {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
if s.logger != nil {
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", "pairing_step_failed")
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_step_failed"))
}
if delay < 15*time.Second {
delay *= 2
}
}
select {
case <-workerContext.Done():
return
case <-time.After(delay):
}
}
}()
}
func (s *Server) stopIdentityPairingWorker(pairingID string) <-chan struct{} {
if value, ok := s.identityPairingWorkers.Load(pairingID); ok {
worker := value.(*identityPairingWorker)
worker.cancel()
return worker.done
}
done := make(chan struct{})
close(done)
return done
}
func (s *Server) startIdentityPairingCleanupWorker(pairingID string, processingDone <-chan struct{}) {
if processingDone == nil {
processingDone = s.stopIdentityPairingWorker(pairingID)
}
if _, loaded := s.identityCleanupWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
return
}
go func() {
defer s.identityCleanupWorkers.Delete(pairingID)
if processingDone != nil {
select {
case <-s.ctx.Done():
return
case <-processingDone:
}
}
delay := time.Second
for {
pairing, err := s.identityPairing.Cleanup(s.ctx, pairingID)
if err == nil {
if pairing.CleanupStatus == identity.PairingCleanupCompleted {
return
}
delay = time.Second
} else {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, identity.ErrPairingNotCancellable) {
return
}
if s.logger != nil {
s.logger.Warn("identity pairing cleanup failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_cleanup_failed"))
}
if delay < 15*time.Second {
delay *= 2

View File

@ -1,9 +1,12 @@
package httpapi
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
func TestRequiredIdentityWriteHeaders(t *testing.T) {
@ -28,6 +31,46 @@ func TestIdentityErrorProjectionProtectsInternalDetails(t *testing.T) {
}
}
func TestIdentityPairingRecoveryErrorsUseStableConflictResponses(t *testing.T) {
for _, test := range []struct {
err error
code string
}{
{err: identity.ErrPairingInProgress, code: "IDENTITY_PAIRING_IN_PROGRESS"},
{err: identity.ErrPairingNotCancellable, code: "IDENTITY_PAIRING_NOT_CANCELLABLE"},
{err: identity.ErrPairingConflictNotResolvable, code: "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"},
} {
status, message, code := identityErrorProjection(test.err)
if status != http.StatusConflict || code != test.code || message == "" || errors.Is(assertionError(message), test.err) {
t.Fatalf("err=%v status=%d code=%q message=%q", test.err, status, code, message)
}
}
}
func TestIdentityWriteHashScopesPairingCancellationToTarget(t *testing.T) {
type cancellationRequest struct {
PairingID string `json:"pairingId"`
}
first := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-a"})
second := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-b"})
if first == second {
t.Fatal("pairing cancellation idempotency hash must include the target pairing")
}
}
func TestIdentityWriteAuditFailsClosedBeforeMutation(t *testing.T) {
server := &Server{}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
recorder := httptest.NewRecorder()
if _, ok := server.requireIdentityConfigurationAudit(recorder, request, "revision.disable", "active", "trace-test"); ok {
t.Fatal("identity write unexpectedly continued without durable audit storage")
}
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("audit failure status=%d, want 503", recorder.Code)
}
}
type assertionError string
func (err assertionError) Error() string { return string(err) }

View File

@ -0,0 +1,126 @@
package httpapi
import (
"context"
"log/slog"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
const identityPairingReconcileInterval = 5 * time.Second
type identityPairingReconciliationStore interface {
PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error)
CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error)
PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error)
}
type identityPairingRestorer interface {
RestoreCompletedSecurityEvents(context.Context, string) error
}
func (s *Server) reconcileCanonicalIdentityPairing() {
reconcileCanonicalIdentityPairing(
s.ctx,
s.store,
s.identityPairing,
&s.identityRestoredPairings,
s.startIdentityPairingWorker,
func(pairingID string) { s.startIdentityPairingCleanupWorker(pairingID, nil) },
s.logger,
)
}
func (s *Server) runIdentityPairingCoordinator() {
ticker := time.NewTicker(identityPairingReconcileInterval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.reconcileIdentityRuntime()
s.reconcileCanonicalIdentityPairing()
}
}
}
func reconcileCanonicalIdentityPairing(
ctx context.Context,
repository identityPairingReconciliationStore,
restorer identityPairingRestorer,
restored *sync.Map,
startWorker func(string),
startCleanup func(string),
logger *slog.Logger,
) {
cleanups, cleanupErr := repository.PendingIdentityPairingCleanups(ctx)
if cleanupErr != nil {
if logger != nil {
logger.Warn("pending identity pairing cleanups could not be resumed", "error_category", "pairing_cleanup_resume_failed")
}
} else {
for _, pairing := range cleanups {
startCleanup(pairing.ID)
}
}
pending, err := repository.PendingIdentityPairingExchanges(ctx)
if err != nil {
if logger != nil {
logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed")
}
return
}
if len(pending) > 0 {
forgetOtherRestoredPairings(restored, "")
startWorker(pending[0].ID)
return
}
completed, err := repository.CompletedIdentityPairingsAwaitingActivation(ctx)
if err != nil {
if logger != nil {
logger.Warn("completed identity pairings could not be loaded", "error_category", "pairing_receiver_restore_failed")
}
return
}
if len(completed) == 0 {
forgetOtherRestoredPairings(restored, "")
return
}
pairingID := completed[0].ID
forgetOtherRestoredPairings(restored, pairingID)
if _, loaded := restored.LoadOrStore(pairingID, struct{}{}); loaded {
return
}
if err := restorer.RestoreCompletedSecurityEvents(ctx, pairingID); err != nil {
restored.Delete(pairingID)
if logger != nil {
logger.Warn("completed identity pairing receiver could not be restored", "pairing_id", pairingID, "error_category", "pairing_receiver_restore_failed")
}
}
}
func (s *Server) reconcileIdentityRuntime() {
if s.identityRuntime == nil || !s.identityRuntime.ReconciliationRequired() {
return
}
ctx, cancel := context.WithTimeout(s.ctx, 5*time.Second)
defer cancel()
if err := s.identityRuntime.ReconcileActive(ctx); err != nil && s.logger != nil {
s.logger.Warn("identity runtime reconciliation failed and will retry", "error_category", "identity_runtime_reconciliation_failed")
}
}
func forgetOtherRestoredPairings(restored *sync.Map, keepID string) {
restored.Range(func(key, _ any) bool {
if key != keepID {
restored.Delete(key)
}
return true
})
}

View File

@ -0,0 +1,150 @@
package httpapi
import (
"context"
"errors"
"sync"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
type identityPairingCoordinatorStore struct {
pending []identity.PairingExchange
completed []identity.PairingExchange
cleanups []identity.PairingExchange
pendingErr error
completedErr error
cleanupErr error
}
func (store identityPairingCoordinatorStore) PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error) {
return store.pending, store.pendingErr
}
func (store identityPairingCoordinatorStore) CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error) {
return store.completed, store.completedErr
}
func (store identityPairingCoordinatorStore) PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error) {
return store.cleanups, store.cleanupErr
}
type identityPairingCoordinatorRestorer struct {
calls int
err error
}
func (restorer *identityPairingCoordinatorRestorer) RestoreCompletedSecurityEvents(context.Context, string) error {
restorer.calls++
return restorer.err
}
func TestReconcileCanonicalIdentityPairingRetriesFailedRestore(t *testing.T) {
repository := identityPairingCoordinatorStore{
completed: []identity.PairingExchange{{ID: "pairing-1"}},
}
restorer := &identityPairingCoordinatorRestorer{err: errors.New("SecretStore temporarily unavailable")}
restored := &sync.Map{}
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
if restorer.calls != 2 {
t.Fatalf("restore calls = %d, want 2", restorer.calls)
}
}
func TestReconcileCanonicalIdentityPairingRestoresSuccessfulPairingOnce(t *testing.T) {
repository := identityPairingCoordinatorStore{
completed: []identity.PairingExchange{{ID: "pairing-1"}},
}
restorer := &identityPairingCoordinatorRestorer{}
restored := &sync.Map{}
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
if restorer.calls != 1 {
t.Fatalf("restore calls = %d, want 1", restorer.calls)
}
}
func TestReconcileCanonicalIdentityPairingPrioritizesPendingWork(t *testing.T) {
repository := identityPairingCoordinatorStore{
pending: []identity.PairingExchange{{ID: "pending-pairing"}},
completed: []identity.PairingExchange{{ID: "completed-pairing"}},
}
restorer := &identityPairingCoordinatorRestorer{}
restored := &sync.Map{}
started := ""
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(pairingID string) {
started = pairingID
}, func(string) {}, nil)
if started != "pending-pairing" {
t.Fatalf("started pairing = %q, want pending-pairing", started)
}
if restorer.calls != 0 {
t.Fatalf("restore calls = %d, want 0", restorer.calls)
}
}
func TestReconcileCanonicalIdentityPairingForgetsTerminalPairing(t *testing.T) {
restored := &sync.Map{}
restored.Store("terminal-pairing", struct{}{})
reconcileCanonicalIdentityPairing(
context.Background(),
identityPairingCoordinatorStore{},
&identityPairingCoordinatorRestorer{},
restored,
func(string) {},
func(string) {},
nil,
)
if _, ok := restored.Load("terminal-pairing"); ok {
t.Fatal("terminal pairing restore marker was not removed")
}
}
func TestReconcileCanonicalIdentityPairingResumesCommittedCleanup(t *testing.T) {
repository := identityPairingCoordinatorStore{
cleanups: []identity.PairingExchange{{ID: "cancelled-pairing"}},
}
restorer := &identityPairingCoordinatorRestorer{}
restored := &sync.Map{}
startedCleanup := ""
reconcileCanonicalIdentityPairing(
context.Background(),
repository,
restorer,
restored,
func(string) {},
func(pairingID string) { startedCleanup = pairingID },
nil,
)
if startedCleanup != "cancelled-pairing" {
t.Fatalf("started cleanup = %q, want cancelled-pairing", startedCleanup)
}
}
func TestCleanupCoordinatorCancelsPairingWorkerBeforeCleanup(t *testing.T) {
serverContext, cancelServer := context.WithCancel(context.Background())
cancelServer()
workerContext, cancelWorker := context.WithCancel(context.Background())
server := &Server{ctx: serverContext}
server.identityPairingWorkers.Store("pairing-1", &identityPairingWorker{cancel: cancelWorker, done: make(chan struct{})})
server.startIdentityPairingCleanupWorker("pairing-1", nil)
select {
case <-workerContext.Done():
default:
t.Fatal("coordinator cleanup did not cancel the pairing worker first")
}
}

View File

@ -79,6 +79,9 @@ func (s *Server) currentIdentityRuntime() *identityRequestRuntime {
}
func (s *Server) currentSecurityEventManager() *ssfreceiver.ConnectionManager {
if s.identityRuntime != nil {
return s.identityRuntime.SecurityEventManager()
}
if runtime := s.currentIdentityRuntime(); runtime != nil {
return runtime.SecurityEvents
}

View File

@ -0,0 +1,26 @@
package httpapi
import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestLocalLoginPolicyAlwaysPreservesBreakGlassManager(t *testing.T) {
serverMain := &Server{cfg: config.Config{IdentityMode: "server-main"}}
if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"manager"}}) {
t.Fatal("server-main mode rejected a local break-glass manager")
}
if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"admin"}}) {
t.Fatal("server-main mode rejected a local break-glass admin")
}
if serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) {
t.Fatal("server-main mode accepted an ordinary local user")
}
hybrid := &Server{cfg: config.Config{IdentityMode: "hybrid"}}
if !hybrid.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) {
t.Fatal("hybrid mode rejected an ordinary local user")
}
}

View File

@ -281,7 +281,7 @@ func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store,
draft, err := identity.NewDraft(identity.PairingInput{
AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178",
LocalTenantKey: localTenantKey, LegacyJWTEnabled: true,
})
}, "test")
if err != nil {
t.Fatalf("create OIDC JIT draft: %v", err)
}
@ -304,7 +304,7 @@ func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store,
Capabilities: []string{"oidc_login", "api_access"}, Audience: "gateway-api", Scopes: []string{"gateway.access"},
Clients: identity.ManifestClients{BrowserLogin: &identity.ManifestClient{ClientID: "gateway-public-test"}},
},
SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test",
SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test", AppEnv: "test",
})
if err != nil {
t.Fatalf("apply OIDC JIT manifest: %v", err)

View File

@ -357,7 +357,7 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
runtime := s.currentIdentityRuntime()
if runtime == nil || !runtime.BrowserEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) {
if runtime == nil || !runtime.BrowserEnabled || hasExplicitCredential(r) {
next.ServeHTTP(w, r)
return
}
@ -366,7 +366,7 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
return
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) {
if origin != "" && !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) || origin == "" && !isSafeHTTPMethod(r.Method) {
writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF)
return
}
@ -384,8 +384,16 @@ func isSafeHTTPMethod(method string) bool {
}
func hasExplicitCredential(r *http.Request) bool {
return strings.TrimSpace(r.Header.Get("Authorization")) != "" ||
return extractBearerCredential(r.Header.Get("Authorization")) != "" ||
strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" ||
strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" ||
strings.TrimSpace(r.URL.Query().Get("key")) != ""
strings.HasPrefix(strings.TrimSpace(r.URL.Query().Get("key")), "sk-")
}
func extractBearerCredential(value string) string {
fields := strings.Fields(value)
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
return fields[1]
}
return ""
}

View File

@ -161,6 +161,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
}{
{name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden},
{name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden},
{name: "foreign origin cannot read cookie authenticated data", method: http.MethodGet, origin: "https://evil.example", wantStatus: http.StatusForbidden},
{name: "allowed origin", method: http.MethodPatch, origin: "https://gateway.example.com", wantStatus: http.StatusNoContent},
{name: "safe request", method: http.MethodGet, wantStatus: http.StatusNoContent},
{name: "explicit bearer bypasses cookie csrf", method: http.MethodPost, bearer: true, wantStatus: http.StatusNoContent},
@ -181,6 +182,94 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
}
}
func TestOIDCSessionCSRFMalformedAuthorizationCannotBypassForeignOrigin(t *testing.T) {
authenticator := auth.New("local-jwt-secret", "", "")
authenticator.OIDCSessionResolver = func(context.Context, string) (*auth.User, error) {
return &auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, nil
}
server := &Server{
auth: authenticator,
identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"},
identityTestBrowserEnabled: true,
}
called := false
handler := server.protectOIDCSessionCookie(server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})))
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
request.Header.Set("Origin", "https://evil.example.com")
request.Header.Set("Authorization", "malformed")
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "manager-session"})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden || called {
t.Fatalf("malformed Authorization CSRF status=%d handler_called=%t", recorder.Code, called)
}
}
func TestAdminRouteRejectsManagerJWTInQueryAndAcceptsAuthorizationHeader(t *testing.T) {
authenticator := auth.New("local-jwt-secret", "", "")
managerToken, err := authenticator.SignJWT(&auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
if err != nil {
t.Fatal(err)
}
server := &Server{auth: authenticator}
handler := server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
queryRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil)
queryRecorder := httptest.NewRecorder()
handler.ServeHTTP(queryRecorder, queryRequest)
if queryRecorder.Code != http.StatusUnauthorized {
t.Fatalf("query manager JWT status=%d, want 401", queryRecorder.Code)
}
headerRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
headerRequest.Header.Set("Authorization", "Bearer "+managerToken)
headerRecorder := httptest.NewRecorder()
handler.ServeHTTP(headerRecorder, headerRequest)
if headerRecorder.Code != http.StatusNoContent {
t.Fatalf("Authorization manager JWT status=%d, want 204", headerRecorder.Code)
}
}
func TestCORSUsesOnlyCurrentActiveIdentityWebOriginWithoutRestart(t *testing.T) {
server := &Server{
cfg: config.Config{CORSAllowedOrigin: "https://bootstrap.example.com"},
identityTestRevision: identity.Revision{
ID: "active-revision", State: identity.RevisionActive, WebBaseURL: "https://gateway.example.com",
},
}
handler := server.cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
request := func(origin string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodOptions, "/api/admin/system/identity/configuration", nil)
r.Header.Set("Origin", origin)
r.Header.Set("Access-Control-Request-Method", http.MethodGet)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
active := request("https://gateway.example.com")
if active.Header().Get("Access-Control-Allow-Origin") != "https://gateway.example.com" || active.Header().Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("active Web origin headers=%v", active.Header())
}
if evil := request("https://evil.example.com"); evil.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("evil origin was allowed: headers=%v", evil.Header())
}
server.identityTestRevision = identity.Revision{}
if disabled := request("https://gateway.example.com"); disabled.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("disabled Revision retained dynamic origin: headers=%v", disabled.Header())
}
if bootstrap := request("https://bootstrap.example.com"); bootstrap.Header().Get("Access-Control-Allow-Origin") != "https://bootstrap.example.com" {
t.Fatalf("deployment bootstrap origin stopped working: headers=%v", bootstrap.Header())
}
}
func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
server := &Server{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)

View File

@ -78,10 +78,28 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque
defer clear(secret)
secretDigest := sha256.Sum256(secret)
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:]))
s.securityEventManagementMu.Lock()
defer s.securityEventManagementMu.Unlock()
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
return
}
if current, err := manager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) {
current, currentErr := manager.Get(r.Context())
switch {
case currentErr == nil:
if !matchConnectionVersion(w, r, current.Version) {
return
}
case errors.Is(currentErr, store.ErrSecurityEventConnectionNotFound):
current = ssfreceiver.ConnectionView{}
if !matchConnectionVersion(w, r, 0) {
return
}
default:
writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable")
return
}
auditID, ok := s.requireSecurityEventConnectionAudit(w, r, "connect", traceID, current)
if !ok {
return
}
connection, err := manager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey)
@ -89,13 +107,15 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque
s.writeSecurityEventConnectionError(w, r, manager, "connect", traceID, err)
return
}
s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection)
s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, auditID, connection)
}
func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) {
traceID := ensureSecurityEventTraceID(w, r)
s.securityEventManagementMu.Lock()
defer s.securityEventManagementMu.Unlock()
manager := s.currentSecurityEventManager()
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID)
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID)
if !ok {
return
}
@ -104,13 +124,15 @@ func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Re
s.writeSecurityEventConnectionError(w, r, manager, "verify", traceID, err)
return
}
s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection)
s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, auditID, connection)
}
func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) {
traceID := ensureSecurityEventTraceID(w, r)
s.securityEventManagementMu.Lock()
defer s.securityEventManagementMu.Unlock()
manager := s.currentSecurityEventManager()
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID)
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID)
if !ok {
return
}
@ -119,13 +141,15 @@ func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter,
s.writeSecurityEventConnectionError(w, r, manager, "rotate", traceID, err)
return
}
s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection)
s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, auditID, connection)
}
func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
traceID := ensureSecurityEventTraceID(w, r)
s.securityEventManagementMu.Lock()
defer s.securityEventManagementMu.Unlock()
manager := s.currentSecurityEventManager()
idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID)
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID)
if !ok {
return
}
@ -134,7 +158,7 @@ func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Re
s.writeSecurityEventConnectionError(w, r, manager, "disconnect", traceID, err)
return
}
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection)
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, auditID, connection)
}
func (s *Server) securityEventPrerequisites() map[string]any {
@ -164,25 +188,32 @@ func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (s
return value, true
}
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, bool) {
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, string, bool) {
if manager == nil {
writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found")
return "", "", false
return "", "", "", false
}
idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r)
if !ok {
return "", "", false
return "", "", "", false
}
requestHash := securityEventOperationHash(operation, normalizedConnectionETag(r.Header.Get("If-Match")))
if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) {
return "", "", false
return "", "", "", false
}
connection, err := manager.Get(r.Context())
if err != nil {
s.writeSecurityEventConnectionError(w, r, manager, operation, traceID, err)
return "", "", false
return "", "", "", false
}
return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version)
if !matchConnectionVersion(w, r, connection.Version) {
return "", "", "", false
}
auditID, ok := s.requireSecurityEventConnectionAudit(w, r, operation, traceID, connection)
if !ok {
return "", "", "", false
}
return idempotencyKey, requestHash, auditID, true
}
func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string) bool {
@ -216,8 +247,7 @@ func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Req
return true
}
func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID string, connection ssfreceiver.ConnectionView) {
auditID := s.recordSecurityEventConnectionAudit(r, operation, "success", "", traceID, connection)
func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID, auditID string, connection ssfreceiver.ConnectionView) {
if auditID != "" {
w.Header().Set("X-Audit-Id", auditID)
}
@ -328,6 +358,16 @@ func (s *Server) recordSecurityEventConnectionAudit(r *http.Request, operation,
return audit.ID
}
func (s *Server) requireSecurityEventConnectionAudit(w http.ResponseWriter, r *http.Request, operation, traceID string, connection ssfreceiver.ConnectionView) (string, bool) {
auditID := s.recordSecurityEventConnectionAudit(r, operation, "requested", "", traceID, connection)
if auditID == "" {
writeError(w, http.StatusServiceUnavailable, "security event audit is unavailable; operation was not executed", "security_event_audit_unavailable")
return "", false
}
w.Header().Set("X-Audit-Id", auditID)
return auditID, true
}
func securityEventConnectionAuditInput(r *http.Request, actor *auth.User, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) store.AuditLogInput {
input := store.AuditLogInput{
Category: "identity", Action: "identity.security_event_connection." + operation,

View File

@ -43,6 +43,16 @@ func TestSecurityEventConnectionWriteHeaders(t *testing.T) {
if _, ok := requiredConnectionIdempotencyKey(recorder, request); ok || recorder.Code != http.StatusBadRequest {
t.Fatalf("missing Idempotency-Key status=%d", recorder.Code)
}
request = httptest.NewRequest(http.MethodPut, "/connection", nil)
recorder = httptest.NewRecorder()
if matchConnectionVersion(recorder, request, 0) || recorder.Code != http.StatusPreconditionRequired {
t.Fatalf("initial connection without If-Match status=%d", recorder.Code)
}
request.Header.Set("If-Match", `W/"0"`)
recorder = httptest.NewRecorder()
if !matchConnectionVersion(recorder, request, 0) {
t.Fatalf("initial zero version was rejected: status=%d", recorder.Code)
}
request = httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
request.Header.Set("If-Match", `W/"7"`)

View File

@ -18,9 +18,13 @@ import "net/http"
// @Failure 503 {object} map[string]string
// @Router /api/v1/security-events/ssf [post]
func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) {
if s.securityEventReceiver == nil {
receiver := s.securityEventReceiver
if s.identityRuntime != nil {
receiver = s.identityRuntime.SecurityEventReceiver()
}
if receiver == nil {
http.NotFound(w, r)
return
}
s.securityEventReceiver.ServeHTTP(w, r)
receiver.ServeHTTP(w, r)
}

View File

@ -0,0 +1,56 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
)
type preparedReceiverBuilder struct {
receiver http.Handler
}
func (builder *preparedReceiverBuilder) Build(context.Context, identity.Revision) (*identityruntime.Runtime, error) {
return &identityruntime.Runtime{}, nil
}
func (builder *preparedReceiverBuilder) PreparedSecurityEventReceiver() http.Handler {
return builder.receiver
}
func TestReceiveSecurityEventUsesPreparedReceiverBeforeFirstActivation(t *testing.T) {
called := false
builder := &preparedReceiverBuilder{receiver: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusAccepted)
})}
server := &Server{identityRuntime: identityruntime.NewManager(nil, builder)}
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", nil)
response := httptest.NewRecorder()
server.receiveSecurityEvent(response, request)
if !called || response.Code != http.StatusAccepted {
t.Fatalf("prepared SSF receiver called=%t status=%d", called, response.Code)
}
}
func TestSecurityEventWriteAuditFailsClosedBeforeMutation(t *testing.T) {
server := &Server{}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil)
recorder := httptest.NewRecorder()
if _, ok := server.requireSecurityEventConnectionAudit(
recorder, request, "verify", "trace-test", securityevents.ConnectionView{},
); ok {
t.Fatal("security event write unexpectedly continued without durable audit storage")
}
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("audit failure status=%d, want 503", recorder.Code)
}
}

View File

@ -36,7 +36,10 @@ type Server struct {
identityRuntime *identityruntime.Manager
identityPairing *identity.PairingService
identityManagementMu sync.Mutex
securityEventManagementMu sync.Mutex
identityPairingWorkers sync.Map
identityCleanupWorkers sync.Map
identityRestoredPairings sync.Map
identityTestRevision identity.Revision
identityTestCookieSecure bool
identityTestBrowserEnabled bool
@ -79,6 +82,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
if err != nil {
panic("invalid identity SecretStore: " + err.Error())
}
go ssfreceiver.RunSecurityEventRetirementWorker(ctx, db)
go ssfreceiver.RunIdentitySecretCleanupWorker(ctx, db, secretStore)
runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{
AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute,
HeartbeatInterval: time.Duration(cfg.IdentitySecurityEventHeartbeatIntervalSeconds) * time.Second,
@ -90,15 +95,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
logger.Error("load active identity runtime failed; local management login remains available", "error_category", "identity_runtime_load_failed")
}
server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) {
return identity.NewOnboardingClient(baseURL, nil)
}, server.identityRuntime)
if pending, pendingErr := db.PendingIdentityPairingExchanges(ctx); pendingErr == nil {
for _, pairing := range pending {
server.startIdentityPairingWorker(pairing.ID)
}
} else if logger != nil {
logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed")
}
return identity.NewOnboardingClient(baseURL, nil, cfg.AppEnv)
}, server.identityRuntime, cfg.AppEnv)
server.reconcileCanonicalIdentityPairing()
go server.runIdentityPairingCoordinator()
server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier {
if runtime := server.identityRuntime.Current(); runtime != nil {
return runtime.Verifier
@ -114,8 +114,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
return user, oidcSessionRequestError(resolveErr)
}
server.auth.LegacyJWTEnabledProvider = func() bool {
runtime := server.identityRuntime.Current()
return runtime == nil || runtime.Revision.LegacyJWTEnabled
return server.identityRuntime.LegacyJWTEnabled()
}
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
server.runner.StartAsyncQueueWorker(ctx)
@ -210,6 +209,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("GET /api/admin/system/identity/configuration", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityConfiguration)))
mux.Handle("POST /api/admin/system/identity/pairings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.startIdentityPairing)))
mux.Handle("GET /api/admin/system/identity/pairings/{pairingID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityPairing)))
mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/cancel", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.cancelIdentityPairing)))
mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.retireIdentityPairingSecurityEventConflict)))
mux.Handle("PATCH /api/admin/system/identity/revisions/{revisionID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateIdentityDraftPolicy)))
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/validate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.validateIdentityRevision)))
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/activate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.activateIdentityRevision)))
@ -349,7 +350,7 @@ func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) htt
func (s *Server) cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && originAllowed(origin, s.cfg.CORSAllowedOrigin) {
if origin != "" && s.corsOriginAllowed(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@ -364,10 +365,21 @@ func (s *Server) cors(next http.Handler) http.Handler {
})
}
func (s *Server) corsOriginAllowed(origin string) bool {
if originAllowed(origin, s.cfg.CORSAllowedOrigin) {
return true
}
if s.identityRuntime != nil {
return originMatchesBaseURL(origin, s.identityRuntime.TrustedWebBaseURL())
}
runtime := s.currentIdentityRuntime()
return runtime != nil && originMatchesBaseURL(origin, runtime.Revision.WebBaseURL)
}
func originAllowed(origin string, allowed string) bool {
for _, item := range strings.Split(allowed, ",") {
item = strings.TrimSpace(item)
if item == "*" || strings.EqualFold(origin, item) {
if item != "*" && strings.EqualFold(origin, item) {
return true
}
}

View File

@ -11,10 +11,13 @@ import (
)
var (
ErrRevisionNotFound = errors.New("identity configuration revision not found")
ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state")
ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required")
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
ErrRevisionNotFound = errors.New("identity configuration revision not found")
ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state")
ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required")
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
ErrActiveConfigurationHandoffRequired = errors.New("active identity configuration requires an explicit remote resource handoff before re-pairing")
ErrRollbackConfigurationHandoffRequired = errors.New("rollback requires a fresh remote resource handoff")
ErrSecurityEventRetirementPending = errors.New("active security event connection must retire before identity can be disabled")
)
type RevisionPolicy struct {
@ -111,15 +114,16 @@ type ManifestApplication struct {
SessionEncryptionKeyRef string
TraceID string
AuditID string
AppEnv string
}
func NewDraft(input PairingInput) (Revision, error) {
if _, err := input.ConsumerMetadata(false); err != nil {
func NewDraft(input PairingInput, appEnv string) (Revision, error) {
if _, err := input.ConsumerMetadata(false, appEnv); err != nil {
return Revision{}, err
}
authCenter, _ := exactBaseURL(input.AuthCenterURL)
publicBase, _ := exactBaseURL(input.PublicBaseURL)
webBase, _ := exactBaseURL(input.WebBaseURL)
authCenter, _ := exactBaseURL(input.AuthCenterURL, appEnv)
publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv)
webBase, _ := exactBaseURL(input.WebBaseURL, appEnv)
return Revision{
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1,
AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey),
@ -133,7 +137,7 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro
if revision.State != RevisionDraft {
return Revision{}, ErrRevisionConflict
}
if err := input.Manifest.Validate(); err != nil {
if err := input.Manifest.Validate(input.AppEnv); err != nil {
return Revision{}, err
}
capabilities := make(map[string]bool, len(input.Manifest.Capabilities))
@ -187,17 +191,17 @@ func CanTransition(from, to RevisionState) bool {
}
}
func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMetadata, error) {
authCenter, err := exactBaseURL(input.AuthCenterURL)
func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string) (ConsumerMetadata, error) {
authCenter, err := exactBaseURL(input.AuthCenterURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("auth center URL is invalid")
}
_ = authCenter
publicBase, err := exactBaseURL(input.PublicBaseURL)
publicBase, err := exactBaseURL(input.PublicBaseURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("public base URL is invalid")
}
webBase, err := exactBaseURL(input.WebBaseURL)
webBase, err := exactBaseURL(input.WebBaseURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("web base URL is invalid")
}
@ -215,7 +219,7 @@ func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMeta
return metadata, nil
}
func exactBaseURL(raw string) (string, error) {
func exactBaseURL(raw, appEnv string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("invalid public URL")
@ -225,7 +229,7 @@ func exactBaseURL(raw string) (string, error) {
return "", errors.New("base URL must not contain a path")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && !(scheme == "http" && isLoopbackHost(hostname)) {
if scheme != "https" && !(scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(hostname)) {
return "", errors.New("public URL must use HTTPS")
}
port := parsed.Port()
@ -242,6 +246,49 @@ func exactBaseURL(raw string) (string, error) {
return scheme + "://" + host, nil
}
func isLocalIdentityEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
return true
default:
return false
}
}
// ValidateRevisionURLs re-applies the deployment environment URL policy when
// constructing a Runtime. This protects against legacy or directly persisted
// revisions bypassing the pairing boundary.
func ValidateRevisionURLs(revision Revision, appEnv string) error {
urls := []struct {
label string
raw string
base bool
}{
{label: "auth center", raw: revision.AuthCenterURL, base: true},
{label: "issuer", raw: revision.Issuer},
{label: "public base", raw: revision.PublicBaseURL, base: true},
{label: "web base", raw: revision.WebBaseURL, base: true},
}
for _, candidate := range urls {
var err error
if candidate.base {
_, err = exactBaseURL(candidate.raw, appEnv)
} else {
err = validatePublicIdentityURL(candidate.raw, appEnv)
}
if err != nil {
return errors.New("identity " + candidate.label + " URL must use HTTPS in this environment")
}
}
if revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" {
if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil ||
validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil {
return errors.New("identity security event URLs must use HTTPS in this environment")
}
}
return nil
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true

View File

@ -48,7 +48,7 @@ func TestValidatePairingInputDerivesExactGatewayURIs(t *testing.T) {
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}
metadata, err := input.ConsumerMetadata(true)
metadata, err := input.ConsumerMetadata(true, "production")
if err != nil {
t.Fatal(err)
}
@ -64,7 +64,7 @@ func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) {
{AuthCenterURL: "http://auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"},
{AuthCenterURL: "https://user:password@auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"},
} {
if _, err := input.ConsumerMetadata(false); err == nil {
if _, err := input.ConsumerMetadata(false, "production"); err == nil {
t.Fatalf("unsafe pairing input accepted: %#v", input)
}
}
@ -75,7 +75,7 @@ func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testin
AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com",
LocalTenantKey: "default", LegacyJWTEnabled: true,
})
}, "production")
if err != nil {
t.Fatal(err)
}
@ -87,3 +87,41 @@ func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testin
t.Fatalf("draft exposed onboarding code: %s", payload)
}
}
func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
localInput := PairingInput{
AuthCenterURL: "http://localhost:18000", PublicBaseURL: "http://127.0.0.1:18089",
WebBaseURL: "http://localhost:5178", LocalTenantKey: "default",
}
secureInput := PairingInput{
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}
for _, test := range []struct {
name string
mutate func(*PairingInput)
}{
{name: "auth center", mutate: func(input *PairingInput) { input.AuthCenterURL = localInput.AuthCenterURL }},
{name: "public base", mutate: func(input *PairingInput) { input.PublicBaseURL = localInput.PublicBaseURL }},
{name: "web base", mutate: func(input *PairingInput) { input.WebBaseURL = localInput.WebBaseURL }},
} {
t.Run(test.name, func(t *testing.T) {
input := secureInput
test.mutate(&input)
for _, appEnv := range []string{"", "production", "staging"} {
if _, err := NewDraft(input, appEnv); err == nil {
t.Fatalf("%s accepted loopback HTTP %s URL", appEnv, test.name)
}
}
})
}
for _, appEnv := range []string{"local", "development", "dev", "test"} {
draft, err := NewDraft(localInput, appEnv)
if err != nil {
t.Fatalf("%s rejected loopback HTTP pairing URLs: %v", appEnv, err)
}
if draft.AuthCenterURL != localInput.AuthCenterURL || draft.PublicBaseURL != localInput.PublicBaseURL || draft.WebBaseURL != localInput.WebBaseURL {
t.Fatalf("%s changed normalized loopback URLs: %#v", appEnv, draft)
}
}
}

View File

@ -85,8 +85,8 @@ type CredentialDelivery struct {
Version int64 `json:"-"`
}
func (manifest ManifestV1) Validate() error {
if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer) != nil {
func (manifest ManifestV1) Validate(appEnv string) error {
if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer, appEnv) != nil {
return errors.New("application manifest identity metadata is invalid")
}
if _, err := uuid.Parse(manifest.TenantID); err != nil {
@ -116,9 +116,12 @@ func (manifest ManifestV1) Validate() error {
if capabilities["api_access"] && strings.TrimSpace(manifest.Audience) == "" {
return errors.New("application manifest audience is missing")
}
if capabilities["session_revocation"] {
if manifest.SecurityEvents == nil || validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer) != nil ||
validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" {
if capabilities["session_revocation"] && manifest.SecurityEvents == nil {
return errors.New("application manifest security event metadata is invalid")
}
if manifest.SecurityEvents != nil {
if validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer, appEnv) != nil ||
validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint, appEnv) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" {
return errors.New("application manifest security event metadata is invalid")
}
}
@ -133,8 +136,8 @@ func (manifest ManifestV1) Validate() error {
return nil
}
func validatePublicIdentityURL(raw string) error {
_, err := exactBaseURL(raw)
func validatePublicIdentityURL(raw, appEnv string) error {
_, err := exactBaseURL(raw, appEnv)
if err == nil {
return nil
}
@ -145,7 +148,7 @@ func validatePublicIdentityURL(raw string) error {
return errors.New("identity URL is invalid")
}
scheme := strings.ToLower(parsed.URL.Scheme)
if scheme == "https" || scheme == "http" && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) {
if scheme == "https" || scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) {
return nil
}
return errors.New("identity URL must use HTTPS")
@ -154,10 +157,11 @@ func validatePublicIdentityURL(raw string) error {
type OnboardingClient struct {
baseURL string
client *http.Client
appEnv string
}
func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient, error) {
normalized, err := exactBaseURL(baseURL)
func NewOnboardingClient(baseURL string, base *http.Client, appEnv string) (*OnboardingClient, error) {
normalized, err := exactBaseURL(baseURL, appEnv)
if err != nil {
return nil, errors.New("auth center URL is invalid")
}
@ -169,7 +173,7 @@ func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient,
client.Timeout = 10 * time.Second
}
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
return &OnboardingClient{baseURL: normalized, client: &client}, nil
return &OnboardingClient{baseURL: normalized, client: &client, appEnv: appEnv}, nil
}
func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) {
@ -208,7 +212,7 @@ func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange
if err != nil || status != http.StatusOK {
return CredentialDelivery{}, onboardingProtocolError(err)
}
if err := output.Manifest.Validate(); err != nil {
if err := output.Manifest.Validate(client.appEnv); err != nil {
return CredentialDelivery{}, err
}
output.Version, err = parseWeakETag(etag)

View File

@ -39,7 +39,7 @@ func TestOnboardingClientKeepsCodeAndExchangeTokenOutOfURLs(t *testing.T) {
}))
defer server.Close()
client, err := NewOnboardingClient(server.URL, server.Client())
client, err := NewOnboardingClient(server.URL, server.Client(), "production")
if err != nil {
t.Fatal(err)
}
@ -63,17 +63,17 @@ func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *te
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},
}
if err := valid.Validate(); err != nil {
if err := valid.Validate("production"); err != nil {
t.Fatal(err)
}
invalid := valid
invalid.Capabilities = []string{"session_revocation"}
if err := invalid.Validate(); err == nil {
if err := invalid.Validate("production"); err == nil {
t.Fatal("manifest with missing capability dependencies was accepted")
}
invalid = valid
invalid.SchemaVersion = 2
if err := invalid.Validate(); err == nil {
if err := invalid.Validate("production"); err == nil {
t.Fatal("unsupported manifest schema was accepted")
}
}
@ -87,7 +87,7 @@ func TestOnboardingClientRejectsRedirects(t *testing.T) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
}))
defer redirect.Close()
client, err := NewOnboardingClient(redirect.URL, redirect.Client())
client, err := NewOnboardingClient(redirect.URL, redirect.Client(), "production")
if err != nil {
t.Fatal(err)
}
@ -95,3 +95,61 @@ func TestOnboardingClientRejectsRedirects(t *testing.T) {
t.Fatal("redirecting onboarding endpoint was accepted")
}
}
func TestManifestAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
manifest := ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/easyai",
TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
Capabilities: []string{"machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"gateway.access"},
Clients: ManifestClients{MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{
TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf",
Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
},
}
for _, test := range []struct {
name string
mutate func(*ManifestV1)
}{
{name: "OIDC issuer", mutate: func(value *ManifestV1) { value.Issuer = "http://localhost:18003/issuer/easyai" }},
{name: "SSF issuer", mutate: func(value *ManifestV1) { value.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf" }},
{name: "SSF configuration", mutate: func(value *ManifestV1) {
value.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf"
}},
} {
t.Run(test.name, func(t *testing.T) {
candidate := manifest
securityEvents := *manifest.SecurityEvents
candidate.SecurityEvents = &securityEvents
test.mutate(&candidate)
if err := candidate.Validate("production"); err == nil {
t.Fatalf("production accepted loopback HTTP %s URL", test.name)
}
})
}
localManifest := manifest
localSecurityEvents := *manifest.SecurityEvents
localManifest.SecurityEvents = &localSecurityEvents
localManifest.Issuer = "http://localhost:18003/issuer/easyai"
localManifest.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf"
localManifest.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf"
if err := localManifest.Validate("test"); err != nil {
t.Fatalf("test environment rejected loopback HTTP manifest URLs: %v", err)
}
localManifest.Capabilities = []string{"machine_to_machine"}
localManifest.Issuer = manifest.Issuer
if err := localManifest.Validate("production"); err == nil {
t.Fatal("production accepted optional loopback HTTP security event metadata")
}
}
func TestOnboardingClientAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
if _, err := NewOnboardingClient(server.URL, server.Client(), "production"); err == nil {
t.Fatal("production accepted loopback HTTP Auth Center URL")
}
if _, err := NewOnboardingClient(server.URL, server.Client(), "development"); err != nil {
t.Fatalf("development rejected loopback HTTP Auth Center URL: %v", err)
}
}

View File

@ -5,6 +5,7 @@ import (
"crypto/rand"
"errors"
"strings"
"sync"
"time"
"github.com/google/uuid"
@ -20,22 +21,45 @@ const (
PairingCompleted PairingStatus = "completed"
PairingFailed PairingStatus = "failed"
PairingExpired PairingStatus = "expired"
PairingCancelled PairingStatus = "cancelled"
)
type PairingCleanupStatus string
const (
PairingCleanupNone PairingCleanupStatus = "none"
PairingCleanupPending PairingCleanupStatus = "pending"
PairingCleanupCompleted PairingCleanupStatus = "completed"
)
var (
ErrPairingInProgress = errors.New("an identity pairing is already in progress")
ErrPairingNotCancellable = errors.New("identity pairing cannot be cancelled")
ErrPairingConflictNotResolvable = errors.New("identity pairing has no resolvable security event conflict")
)
const (
identityPairingStartReservationTTL = 2 * time.Minute
identityPairingStartReleaseTimeout = 5 * time.Second
)
type PairingExchange struct {
ID string `json:"id"`
RevisionID string `json:"revisionId"`
RemoteExchangeID string `json:"remoteExchangeId"`
ExchangeTokenRef string `json:"-"`
Status PairingStatus `json:"status"`
RemoteVersion int64 `json:"remoteVersion"`
ExpiresAt time.Time `json:"expiresAt"`
Version int64 `json:"version"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
AuthCenterAuditID string `json:"authCenterAuditId,omitempty"`
LastTraceID string `json:"lastTraceId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
RevisionID string `json:"revisionId"`
RemoteExchangeID string `json:"remoteExchangeId"`
ExchangeTokenRef string `json:"-"`
Status PairingStatus `json:"status"`
CleanupStatus PairingCleanupStatus `json:"cleanupStatus"`
RemoteVersion int64 `json:"remoteVersion"`
ExpiresAt time.Time `json:"expiresAt"`
Version int64 `json:"version"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
AuthCenterAuditID string `json:"authCenterAuditId,omitempty"`
LastTraceID string `json:"lastTraceId,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CleanupCompletedAt *time.Time `json:"cleanupCompletedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PairingExchangeUpdate struct {
@ -46,12 +70,20 @@ type PairingExchangeUpdate struct {
}
type PairingRepository interface {
CreateIdentityConfigurationRevision(context.Context, Revision) (Revision, error)
ReserveIdentityPairingStart(context.Context, string, time.Time) error
ReleaseIdentityPairingStart(context.Context, string) error
CommitIdentityPairingStart(context.Context, Revision, PairingExchange) (PairingExchange, error)
IdentityPairingStartBlocked(context.Context) (bool, error)
IdentityConfigurationRevision(context.Context, string) (Revision, error)
ApplyIdentityManifest(context.Context, string, int64, ManifestApplication) (Revision, error)
CreateIdentityPairingExchange(context.Context, PairingExchange) (PairingExchange, error)
IdentityPairingExchange(context.Context, string) (PairingExchange, error)
UpdateIdentityPairingExchange(context.Context, string, int64, PairingExchangeUpdate) (PairingExchange, error)
RecordIdentityPairingRetryFailure(context.Context, string, PairingStatus, string) (PairingExchange, error)
CancelIdentityPairingExchange(context.Context, string, int64, string, string) (PairingExchange, error)
CompleteIdentityPairingCleanup(context.Context, string, int64) (PairingExchange, error)
RecordIdentityPairingCleanupFailure(context.Context, string, string) (PairingExchange, error)
QueueIdentitySecretCleanup(context.Context, string, time.Time) error
RemovePendingIdentitySecretCleanup(context.Context, string) (bool, error)
}
type PairingSecretStore interface {
@ -70,6 +102,11 @@ type OnboardingRemote interface {
type SecurityEventPreparer interface {
PrepareSecurityEvents(context.Context, Revision, []byte) error
CleanupPreparedSecurityEvents(context.Context, Revision) error
}
type SecurityEventConflictResolver interface {
RetireConflictingSecurityEvents(context.Context, Revision) error
}
type PairingService struct {
@ -77,22 +114,37 @@ type PairingService struct {
secrets PairingSecretStore
remote func(string) (OnboardingRemote, error)
security SecurityEventPreparer
appEnv string
operations sync.Map
}
func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer) *PairingService {
return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security}
func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer, appEnvs ...string) *PairingService {
appEnv := "production"
if len(appEnvs) > 0 {
appEnv = appEnvs[0]
}
return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security, appEnv: appEnv}
}
func (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) {
draft, err := NewDraft(input)
draft, err := NewDraft(input, service.appEnv)
if err != nil {
return PairingExchange{}, err
}
draft.LastTraceID = strings.TrimSpace(traceID)
draft, err = service.repository.CreateIdentityConfigurationRevision(ctx, draft)
if err != nil {
pairingID := uuid.NewString()
tokenReference := "identity-exchange-" + pairingID
if err := service.repository.ReserveIdentityPairingStart(ctx, pairingID, time.Now().UTC().Add(identityPairingStartReservationTTL)); err != nil {
return PairingExchange{}, err
}
committed := false
defer func() {
if !committed {
releaseContext, cancel := context.WithTimeout(context.Background(), identityPairingStartReleaseTimeout)
defer cancel()
_ = service.repository.ReleaseIdentityPairingStart(releaseContext, pairingID)
}
}()
remote, err := service.remote(draft.AuthCenterURL)
if err != nil {
return PairingExchange{}, err
@ -101,32 +153,211 @@ func (service *PairingService) Start(ctx context.Context, input PairingInput, tr
if err != nil {
return PairingExchange{}, err
}
pairingID := uuid.NewString()
tokenReference := "identity-exchange-" + pairingID
if err := service.secrets.Put(ctx, tokenReference, []byte(claimed.ExchangeToken)); err != nil {
token := []byte(claimed.ExchangeToken)
claimed.ExchangeToken = ""
if err := service.stageIdentitySecret(ctx, tokenReference, token); err != nil {
clear(token)
return PairingExchange{}, err
}
claimed.ExchangeToken = ""
clear(token)
pairing := PairingExchange{
ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference,
Status: PairingMetadataPending, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt,
Status: PairingMetadataPending, CleanupStatus: PairingCleanupNone, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt,
Version: 1, LastTraceID: strings.TrimSpace(traceID),
}
pairing, err = service.repository.CreateIdentityPairingExchange(ctx, pairing)
pairing, err = service.repository.CommitIdentityPairingStart(ctx, draft, pairing)
if err != nil {
_ = service.secrets.Delete(ctx, tokenReference)
// Commit may have succeeded even when its response was lost. The
// staging row is the source of truth: rollback leaves it queued, while
// a successful commit atomically adopts it. Re-queueing here could
// delete a Secret that the new Pairing already references.
return PairingExchange{}, err
}
committed = true
return pairing, nil
}
func (service *PairingService) Cancel(ctx context.Context, pairingID string, expectedVersion int64, traceID, auditID string) (PairingExchange, error) {
return service.repository.CancelIdentityPairingExchange(ctx, pairingID, expectedVersion, strings.TrimSpace(traceID), strings.TrimSpace(auditID))
}
func (service *PairingService) RetireConflictingSecurityEvents(ctx context.Context, pairingID string, expectedVersion int64) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Version != expectedVersion {
return pairing, ErrRevisionConflict
}
if pairing.Status != PairingCredentialsSaved || pairing.CleanupStatus != PairingCleanupNone ||
pairing.LastErrorCategory != "security_event_connection_conflict" {
return pairing, ErrPairingConflictNotResolvable
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return pairing, err
}
if revision.State != RevisionDraft || !revision.SessionRevocation {
return pairing, ErrPairingConflictNotResolvable
}
resolver, ok := service.security.(SecurityEventConflictResolver)
if !ok {
return pairing, errors.New("security event conflict recovery is unavailable")
}
if err := resolver.RetireConflictingSecurityEvents(ctx, revision); err != nil {
return pairing, err
}
return service.repository.RecordIdentityPairingRetryFailure(ctx, pairing.ID, pairing.Status, "security_event_retirement_pending")
}
// RestoreCompletedSecurityEvents reconstructs the prepared Receiver after a
// process restart while a completed Revision is still awaiting validation or
// activation. The machine Secret is read only from SecretStore and cleared
// immediately after use.
func (service *PairingService) RestoreCompletedSecurityEvents(ctx context.Context, pairingID string) error {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return err
}
if pairing.Status != PairingCompleted {
return ErrRevisionConflict
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return err
}
if revision.State == RevisionActive {
return nil
}
if revision.State != RevisionDraft && revision.State != RevisionValidated {
return ErrRevisionConflict
}
if !revision.SessionRevocation {
return nil
}
if service.security == nil || revision.MachineCredentialRef == "" {
return errors.New("completed security event configuration is unavailable")
}
secret, err := service.secrets.Get(ctx, revision.MachineCredentialRef)
if err != nil {
return err
}
defer clear(secret)
if err := service.security.PrepareSecurityEvents(ctx, revision, secret); err != nil {
return err
}
currentRevision, revisionErr := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if revisionErr != nil {
return revisionErr
}
if currentRevision.State == RevisionActive {
return nil
}
currentPairing, pairingErr := service.repository.IdentityPairingExchange(ctx, pairingID)
if pairingErr != nil {
return pairingErr
}
if currentPairing.Status == PairingCompleted &&
(currentRevision.State == RevisionDraft || currentRevision.State == RevisionValidated) {
return nil
}
if cleanupErr := service.security.CleanupPreparedSecurityEvents(ctx, currentRevision); cleanupErr != nil {
return cleanupErr
}
return ErrRevisionConflict
}
func (service *PairingService) Cleanup(ctx context.Context, pairingID string) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Status != PairingCancelled {
return pairing, ErrPairingNotCancellable
}
if pairing.CleanupStatus == PairingCleanupCompleted {
return pairing, nil
}
if pairing.CleanupStatus != PairingCleanupPending {
return pairing, ErrRevisionConflict
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_revision_unavailable", err)
}
if revision.SessionRevocation {
if service.security == nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_security_event_unavailable", errors.New("security event cleanup is unavailable"))
}
if err := service.security.CleanupPreparedSecurityEvents(ctx, revision); err != nil {
return service.recordCleanupFailure(ctx, pairing, pairingCleanupFailureCategory(err), err)
}
}
for _, reference := range []string{pairing.ExchangeTokenRef, revision.MachineCredentialRef, revision.SessionEncryptionKeyRef} {
if reference == "" {
continue
}
if err := service.secrets.Delete(ctx, reference); err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_secret_store_failed", err)
}
}
completed, err := service.repository.CompleteIdentityPairingCleanup(ctx, pairing.ID, pairing.Version)
if err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_finalize_failed", err)
}
return completed, nil
}
func (service *PairingService) recordCleanupFailure(ctx context.Context, pairing PairingExchange, category string, cause error) (PairingExchange, error) {
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
return pairing, cause
}
recorded, err := service.repository.RecordIdentityPairingCleanupFailure(ctx, pairing.ID, category)
if err != nil {
return pairing, cause
}
return recorded, cause
}
func (service *PairingService) Continue(ctx context.Context, pairingID string) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.continuePairing(ctx, pairingID)
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return pairing, err
}
current, currentErr := service.repository.IdentityPairingExchange(ctx, pairingID)
if currentErr != nil || !isPairingInProgress(current.Status) {
return pairing, err
}
category := pairingRetryFailureCategory(current.Status, err)
recorded, recordErr := service.repository.RecordIdentityPairingRetryFailure(ctx, current.ID, current.Status, category)
if recordErr != nil {
return current, err
}
return recorded, err
}
func (service *PairingService) lockPairingOperation(pairingID string) func() {
value, _ := service.operations.LoadOrStore(pairingID, &sync.Mutex{})
mutex := value.(*sync.Mutex)
mutex.Lock()
return mutex.Unlock
}
func (service *PairingService) continuePairing(ctx context.Context, pairingID string) (PairingExchange, error) {
for step := 0; step < 6; step++ {
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired {
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired || pairing.Status == PairingCancelled {
return pairing, nil
}
if !pairing.ExpiresAt.After(time.Now()) {
@ -134,7 +365,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
Status: PairingExpired, RemoteVersion: pairing.RemoteVersion, LastErrorCategory: "exchange_expired",
})
if updateErr == nil {
_ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef)
service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef)
}
return expired, updateErr
}
@ -156,7 +387,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
metadata, metadataErr := PairingInput{
AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL,
WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey,
}.ConsumerMetadata(true)
}.ConsumerMetadata(true, service.appEnv)
if metadataErr != nil {
clear(token)
return PairingExchange{}, metadataErr
@ -224,7 +455,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion)
clear(token)
if completeErr != nil {
return PairingExchange{}, completeErr
return PairingExchange{}, pairingStepError{category: "exchange_completion_failed", cause: completeErr}
}
completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion,
@ -232,7 +463,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
if err != nil {
return PairingExchange{}, err
}
_ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef)
service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef)
return completed, nil
default:
clear(token)
@ -242,8 +473,71 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
return service.repository.IdentityPairingExchange(ctx, pairingID)
}
func isPairingInProgress(status PairingStatus) bool {
switch status {
case PairingMetadataPending, PairingPreparing, PairingReady, PairingCredentialsSaved:
return true
default:
return false
}
}
type safeErrorCategory interface {
SafeErrorCategory() string
}
type pairingStepError struct {
category string
cause error
}
func (err pairingStepError) Error() string { return "identity pairing step failed: " + err.category }
func (err pairingStepError) Unwrap() error { return err.cause }
func (err pairingStepError) SafeErrorCategory() string { return err.category }
func pairingRetryFailureCategory(status PairingStatus, err error) string {
var categorized safeErrorCategory
if errors.As(err, &categorized) && categorized.SafeErrorCategory() == "exchange_completion_failed" {
return "exchange_completion_failed"
}
switch status {
case PairingMetadataPending:
return "metadata_submission_failed"
case PairingPreparing:
return "exchange_status_unavailable"
case PairingReady:
return "credential_delivery_failed"
case PairingCredentialsSaved:
if errors.As(err, &categorized) {
switch categorized.SafeErrorCategory() {
case "configuration_invalid", "connection_conflict", "discovery_failed", "management_token_failed",
"stream_create_failed", "stream_response_invalid", "receiver_activation_failed", "preparation_failed", "retirement_pending",
"credential_handoff_unsafe", "connection_binding_missing", "connection_binding_unavailable",
"connection_binding_invalid", "connection_binding_mismatch":
return "security_event_" + categorized.SafeErrorCategory()
}
}
return "security_event_preparation_failed"
default:
return "pairing_step_failed"
}
}
func pairingCleanupFailureCategory(err error) string {
var categorized safeErrorCategory
if errors.As(err, &categorized) {
switch categorized.SafeErrorCategory() {
case "configuration_invalid", "retirement_pending", "connection_conflict", "connection_cleanup_failed", "secret_cleanup_failed",
"discovery_failed", "management_token_failed", "stream_create_failed", "stream_response_invalid",
"receiver_activation_failed", "preparation_failed", "credential_handoff_unsafe":
return "cleanup_security_event_" + categorized.SafeErrorCategory()
}
}
return "cleanup_security_event_failed"
}
func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) {
if err := delivery.Manifest.Validate(); err != nil {
if err := delivery.Manifest.Validate(service.appEnv); err != nil {
return Revision{}, err
}
machineReference := ""
@ -251,10 +545,10 @@ func (service *PairingService) saveDelivery(ctx context.Context, revision Revisi
if delivery.MachineCredential == nil || delivery.MachineCredential.ClientID != delivery.Manifest.Clients.MachineToMachine.ClientID {
return Revision{}, errors.New("onboarding machine credential is invalid")
}
machineReference = "identity-machine-" + revision.ID
machineReference = "identity-machine-" + uuid.NewString()
secret := []byte(delivery.MachineCredential.ClientSecret)
delivery.MachineCredential.ClientSecret = ""
if err := service.secrets.Put(ctx, machineReference, secret); err != nil {
if err := service.stageIdentitySecret(ctx, machineReference, secret); err != nil {
clear(secret)
return Revision{}, err
}
@ -262,49 +556,77 @@ func (service *PairingService) saveDelivery(ctx context.Context, revision Revisi
}
sessionReference := ""
if delivery.Manifest.Clients.BrowserLogin != nil {
sessionReference = "identity-session-" + revision.ID
sessionReference = "identity-session-" + uuid.NewString()
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
_ = service.secrets.Delete(ctx, machineReference)
_ = service.retireIdentitySecret(ctx, machineReference)
return Revision{}, err
}
if err := service.secrets.Put(ctx, sessionReference, key); err != nil {
if err := service.stageIdentitySecret(ctx, sessionReference, key); err != nil {
clear(key)
_ = service.secrets.Delete(ctx, machineReference)
_ = service.retireIdentitySecret(ctx, machineReference)
return Revision{}, err
}
clear(key)
}
updated, err := service.repository.ApplyIdentityManifest(ctx, revision.ID, revision.Version, ManifestApplication{
Manifest: delivery.Manifest, MachineCredentialRef: machineReference, SessionEncryptionKeyRef: sessionReference,
TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID,
TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID, AppEnv: service.appEnv,
})
if err != nil {
if machineReference != "" {
_ = service.secrets.Delete(ctx, machineReference)
}
if sessionReference != "" {
_ = service.secrets.Delete(ctx, sessionReference)
}
// ApplyIdentityManifest adopts the staged references in the same DB
// transaction as the Revision update. On rollback the staging rows
// remain; on an ambiguous successful commit they are gone. Do not
// re-queue here or a valid active credential could be destroyed.
return Revision{}, err
}
return updated, nil
}
func (service *PairingService) stageIdentitySecret(ctx context.Context, reference string, value []byte) error {
if err := service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC().Add(10*time.Minute)); err != nil {
return err
}
if err := service.secrets.Put(ctx, reference, value); err != nil {
deleteErr := service.secrets.Delete(ctx, reference)
if deleteErr == nil {
_, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
}
return err
}
return nil
}
func (service *PairingService) retireIdentitySecret(ctx context.Context, reference string) error {
if reference == "" {
return nil
}
return service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC())
}
func (service *PairingService) deleteRetiredIdentitySecret(ctx context.Context, reference string) {
if err := service.secrets.Delete(ctx, reference); err == nil {
_, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
}
}
func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) {
status := PairingPreparing
category := ""
switch remote.Status {
case ExchangeReady, ExchangeCredentialDelivered:
status = PairingReady
case ExchangeCompleted:
status = PairingFailed
remote.LastErrorCategory = "remote_state_invalid"
category = "remote_state_invalid"
case ExchangeFailed:
status = PairingFailed
category = "remote_exchange_failed"
case ExchangeExpired:
status = PairingExpired
category = "exchange_expired"
}
return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: status, RemoteVersion: remote.Version, LastErrorCategory: remote.LastErrorCategory, AuthCenterAuditID: remote.AuditID,
Status: status, RemoteVersion: remote.Version, LastErrorCategory: category, AuthCenterAuditID: remote.AuditID,
})
}

View File

@ -2,14 +2,68 @@ package identity
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
)
type pairingRepositoryFake struct {
revision Revision
exchange PairingExchange
revision Revision
exchange PairingExchange
blocked bool
startReservationErr error
startReservationID string
startReservationState string
startReservationCalls int
startReservationReleases int
pendingSecretCleanups map[string]time.Time
commitStartErr error
commitStartAmbiguous bool
applyManifestAmbiguousOnce bool
failCredentialsSavedOnce bool
}
func (f *pairingRepositoryFake) ReserveIdentityPairingStart(_ context.Context, attemptID string, _ time.Time) error {
f.startReservationCalls++
if f.startReservationErr != nil {
return f.startReservationErr
}
if f.blocked || f.startReservationID != "" {
return ErrPairingInProgress
}
f.startReservationID = attemptID
f.startReservationState = "starting"
return nil
}
func (f *pairingRepositoryFake) ReleaseIdentityPairingStart(_ context.Context, attemptID string) error {
if f.startReservationID == attemptID && f.startReservationState == "starting" {
f.startReservationID = ""
f.startReservationState = ""
f.startReservationReleases++
}
return nil
}
func (f *pairingRepositoryFake) CommitIdentityPairingStart(_ context.Context, revision Revision, exchange PairingExchange) (PairingExchange, error) {
if f.commitStartErr != nil && !f.commitStartAmbiguous {
return PairingExchange{}, f.commitStartErr
}
if f.startReservationID != exchange.ID || f.startReservationState != "starting" {
return PairingExchange{}, ErrPairingInProgress
}
if _, staged := f.pendingSecretCleanups[exchange.ExchangeTokenRef]; !staged {
return PairingExchange{}, errors.New("exchange token was not staged")
}
delete(f.pendingSecretCleanups, exchange.ExchangeTokenRef)
f.revision = revision
f.exchange = exchange
f.startReservationState = "paired"
if f.commitStartAmbiguous {
return PairingExchange{}, f.commitStartErr
}
return exchange, nil
}
func (f *pairingRepositoryFake) CreateIdentityConfigurationRevision(_ context.Context, revision Revision) (Revision, error) {
@ -20,12 +74,33 @@ func (f *pairingRepositoryFake) IdentityConfigurationRevision(context.Context, s
return f.revision, nil
}
func (f *pairingRepositoryFake) ApplyIdentityManifest(_ context.Context, _ string, _ int64, applied ManifestApplication) (Revision, error) {
for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} {
if reference == "" {
continue
}
if _, staged := f.pendingSecretCleanups[reference]; !staged {
return Revision{}, errors.New("identity secret was not staged")
}
}
oldMachineReference, oldSessionReference := f.revision.MachineCredentialRef, f.revision.SessionEncryptionKeyRef
revision, err := ApplyManifest(f.revision, applied)
if err != nil {
return Revision{}, err
}
revision.Version++
f.revision = revision
for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} {
delete(f.pendingSecretCleanups, reference)
}
for _, reference := range []string{oldMachineReference, oldSessionReference} {
if reference != "" && reference != applied.MachineCredentialRef && reference != applied.SessionEncryptionKeyRef {
f.pendingSecretCleanups[reference] = time.Now()
}
}
if f.applyManifestAmbiguousOnce {
f.applyManifestAmbiguousOnce = false
return Revision{}, errors.New("manifest commit response lost")
}
return revision, nil
}
func (f *pairingRepositoryFake) CreateIdentityPairingExchange(_ context.Context, exchange PairingExchange) (PairingExchange, error) {
@ -39,9 +114,97 @@ func (f *pairingRepositoryFake) UpdateIdentityPairingExchange(_ context.Context,
if f.exchange.ID != id || f.exchange.Version != expected {
return PairingExchange{}, ErrRevisionConflict
}
if update.Status == PairingCredentialsSaved && f.failCredentialsSavedOnce {
f.failCredentialsSavedOnce = false
return PairingExchange{}, errors.New("pairing status temporarily unavailable")
}
f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion
f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID
f.exchange.Version++
if update.Status == PairingCompleted || update.Status == PairingFailed || update.Status == PairingExpired {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now()
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) RecordIdentityPairingRetryFailure(_ context.Context, id string, status PairingStatus, category string) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Status != status {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.LastErrorCategory = category
return f.exchange, nil
}
func (f *pairingRepositoryFake) IdentityPairingStartBlocked(context.Context) (bool, error) {
return f.blocked, nil
}
func (f *pairingRepositoryFake) QueueIdentitySecretCleanup(_ context.Context, reference string, notBefore time.Time) error {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
current, exists := f.pendingSecretCleanups[reference]
if !exists || notBefore.Before(current) {
f.pendingSecretCleanups[reference] = notBefore
}
return nil
}
func (f *pairingRepositoryFake) RemovePendingIdentitySecretCleanup(_ context.Context, reference string) (bool, error) {
if _, exists := f.pendingSecretCleanups[reference]; !exists {
return false, nil
}
delete(f.pendingSecretCleanups, reference)
return true, nil
}
func (f *pairingRepositoryFake) CancelIdentityPairingExchange(_ context.Context, id string, expected int64, traceID, auditID string) (PairingExchange, error) {
if f.exchange.ID != id || f.revision.State == RevisionActive || f.revision.State == RevisionSuperseded {
return PairingExchange{}, ErrRevisionConflict
}
if f.exchange.Status == PairingCancelled {
return f.exchange, nil
}
if f.exchange.Version != expected {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.Status = PairingCancelled
f.exchange.CleanupStatus = PairingCleanupPending
f.exchange.Version++
now := time.Now().UTC()
f.exchange.CancelledAt = &now
f.revision.State = RevisionFailed
f.revision.LastErrorCategory = "pairing_cancelled"
f.revision.LastTraceID = traceID
f.revision.LastAuditID = auditID
f.revision.Version++
if f.exchange.ExchangeTokenRef != "" {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now()
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) CompleteIdentityPairingCleanup(_ context.Context, id string, expected int64) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Version != expected || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.CleanupStatus = PairingCleanupCompleted
f.exchange.Version++
now := time.Now().UTC()
f.exchange.CleanupCompletedAt = &now
f.revision.MachineCredentialRef = ""
f.revision.SessionEncryptionKeyRef = ""
if f.startReservationState == "paired" && f.startReservationID == f.exchange.ID {
f.startReservationID = ""
f.startReservationState = ""
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) RecordIdentityPairingCleanupFailure(_ context.Context, id string, category string) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.LastErrorCategory = category
return f.exchange, nil
}
@ -74,14 +237,18 @@ func (f *secretStoreFake) Delete(_ context.Context, reference string) error {
type onboardingRemoteFake struct {
claimed ClaimedExchange
claimErr error
claimCalls int
view Exchange
delivery CredentialDelivery
deliveryCalls int
completed bool
completeErr error
}
func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) {
return f.claimed, nil
f.claimCalls++
return f.claimed, f.claimErr
}
func (f *onboardingRemoteFake) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) {
f.view.Status, f.view.Version = ExchangePreparing, 2
@ -99,14 +266,159 @@ func (f *onboardingRemoteFake) DeliverCredential(context.Context, Exchange, stri
}
func (f *onboardingRemoteFake) Complete(context.Context, string, string, string, int64) error {
f.completed = true
return f.completeErr
}
type securityEventPreparerFake struct {
called bool
err error
cleanupCalled bool
cleanupErr error
conflictRetireCalled bool
conflictRetireErr error
conflictRevisionID string
}
type blockingSecurityEventPreparer struct {
prepareStarted chan struct{}
releasePrepare chan struct{}
cleanupStarted chan struct{}
cleanupOnce sync.Once
}
func (preparer *blockingSecurityEventPreparer) PrepareSecurityEvents(context.Context, Revision, []byte) error {
close(preparer.prepareStarted)
<-preparer.releasePrepare
return nil
}
type securityEventPreparerFake struct{ called bool }
func (preparer *blockingSecurityEventPreparer) CleanupPreparedSecurityEvents(context.Context, Revision) error {
preparer.cleanupOnce.Do(func() { close(preparer.cleanupStarted) })
return nil
}
func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error {
f.called = true
return nil
return f.err
}
func (f *securityEventPreparerFake) CleanupPreparedSecurityEvents(context.Context, Revision) error {
f.cleanupCalled = true
return f.cleanupErr
}
func (f *securityEventPreparerFake) RetireConflictingSecurityEvents(_ context.Context, revision Revision) error {
f.conflictRetireCalled = true
f.conflictRevisionID = revision.ID
return f.conflictRetireErr
}
type safeCategoryTestError struct {
category string
message string
}
func (err safeCategoryTestError) Error() string { return err.message }
func (err safeCategoryTestError) SafeErrorCategory() string { return err.category }
func TestPairingStartReservationSerializesAndRecoversFailedClaims(t *testing.T) {
input := PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}
t.Run("existing pairing blocks before remote claim", func(t *testing.T) {
repository := &pairingRepositoryFake{blocked: true}
remote := &onboardingRemoteFake{}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-blocked"); !errors.Is(err, ErrPairingInProgress) {
t.Fatalf("blocked Start error=%v", err)
}
if repository.startReservationCalls != 1 || repository.startReservationReleases != 0 || repository.startReservationID != "" || remote.claimCalls != 0 {
t.Fatalf("reservation/claim lifecycle calls=%d releases=%d id=%q claims=%d",
repository.startReservationCalls, repository.startReservationReleases, repository.startReservationID, remote.claimCalls)
}
})
t.Run("active machine credential blocks destructive rotation before remote claim", func(t *testing.T) {
repository := &pairingRepositoryFake{startReservationErr: ErrActiveConfigurationHandoffRequired}
remote := &onboardingRemoteFake{}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-active"); !errors.Is(err, ErrActiveConfigurationHandoffRequired) {
t.Fatalf("active credential guard error=%v", err)
}
if remote.claimCalls != 0 {
t.Fatal("active credential guard ran after the one-time onboarding code was claimed")
}
})
t.Run("remote failure releases database guard", func(t *testing.T) {
repository := &pairingRepositoryFake{}
remote := &onboardingRemoteFake{claimErr: errors.New("remote unavailable")}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-failed"); err == nil {
t.Fatal("remote claim failure was ignored")
}
if repository.startReservationCalls != 1 || repository.startReservationReleases != 1 || repository.startReservationID != "" || remote.claimCalls != 1 {
t.Fatalf("reservation was not released after remote failure: %#v remote=%#v", repository, remote)
}
})
}
func TestPairingStartLeavesUncommittedExchangeTokenInDurableCleanupQueue(t *testing.T) {
repository := &pairingRepositoryFake{commitStartErr: errors.New("database commit unavailable")}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{claimed: ClaimedExchange{
ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value",
Version: 1, ExpiresAt: time.Now().Add(time.Hour),
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-uncommitted"); err == nil {
t.Fatal("failed pairing commit was ignored")
}
if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 1 || repository.startReservationID != "" || repository.startReservationReleases != 1 {
t.Fatalf("uncommitted token was not left recoverable: secrets=%d cleanup=%d reservation=%q releases=%d",
len(secrets.values), len(repository.pendingSecretCleanups), repository.startReservationID, repository.startReservationReleases)
}
for reference := range secrets.values {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("uncommitted Secret reference %q is not queued for cleanup", reference)
}
}
}
func TestPairingStartAmbiguousCommitNeverRequeuesAdoptedExchangeToken(t *testing.T) {
repository := &pairingRepositoryFake{
commitStartErr: errors.New("commit response lost"), commitStartAmbiguous: true,
}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{claimed: ClaimedExchange{
ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value",
Version: 1, ExpiresAt: time.Now().Add(time.Hour),
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-ambiguous"); err == nil {
t.Fatal("ambiguous pairing commit was not reported")
}
if repository.exchange.ID == "" || repository.revision.ID == "" {
t.Fatal("test did not simulate a committed Pairing")
}
if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 0 {
t.Fatalf("adopted exchange token was re-queued: secrets=%d cleanup=%d", len(secrets.values), len(repository.pendingSecretCleanups))
}
if repository.startReservationID != repository.exchange.ID || repository.startReservationState != "paired" || repository.startReservationReleases != 0 {
t.Fatalf("committed reservation was not retained: id=%q state=%q releases=%d",
repository.startReservationID, repository.startReservationState, repository.startReservationReleases)
}
}
func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T) {
@ -155,6 +467,91 @@ func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T
}
}
func TestPairingRetryNeverOverwritesOrDeletesAdoptedIdentitySecrets(t *testing.T) {
repository := &pairingRepositoryFake{applyManifestAmbiguousOnce: true, failCredentialsSavedOnce: true}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{
claimed: ClaimedExchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
view: Exchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ApplicationID: "22222222-2222-2222-2222-222222222222", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
delivery: CredentialDelivery{Manifest: ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "33333333-3333-3333-3333-333333333333",
ApplicationID: "22222222-2222-2222-2222-222222222222", Audience: "urn:easyai:resource:22222222-2222-2222-2222-222222222222",
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"openid", "gateway.access"},
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:22222222-2222-2222-2222-222222222222"},
}, MachineCredential: &MachineCredential{ClientID: "service", ClientSecret: "placeholder", IssuedAt: time.Now()}},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
pairing, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-retry")
if err != nil {
t.Fatal(err)
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("simulated pairing status persistence failure was ignored")
}
firstMachine := repository.revision.MachineCredentialRef
firstSession := repository.revision.SessionEncryptionKeyRef
if firstMachine == "" || firstSession == "" {
t.Fatalf("first delivery was not adopted: %#v", repository.revision)
}
if _, ok := secrets.values[firstMachine]; !ok {
t.Fatal("first adopted machine Secret was deleted")
}
if _, ok := secrets.values[firstSession]; !ok {
t.Fatal("first adopted session Secret was deleted")
}
for _, reference := range []string{firstMachine, firstSession} {
if _, queued := repository.pendingSecretCleanups[reference]; queued {
t.Fatalf("ambiguously adopted identity Secret %q was re-queued", reference)
}
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("simulated pairing status persistence failure was ignored")
}
secondMachine := repository.revision.MachineCredentialRef
secondSession := repository.revision.SessionEncryptionKeyRef
if secondMachine == firstMachine || secondSession == firstSession {
t.Fatalf("identity Secret references were overwritten in place: first=(%s,%s) second=(%s,%s)", firstMachine, firstSession, secondMachine, secondSession)
}
for _, reference := range []string{firstMachine, firstSession} {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("first superseded identity Secret %q was not queued for cleanup", reference)
}
}
completed, err := service.Continue(context.Background(), pairing.ID)
if err != nil {
t.Fatal(err)
}
if completed.Status != PairingCompleted {
t.Fatalf("retry did not complete: %#v", completed)
}
thirdMachine := repository.revision.MachineCredentialRef
thirdSession := repository.revision.SessionEncryptionKeyRef
if thirdMachine == secondMachine || thirdSession == secondSession {
t.Fatalf("second retry overwrote identity Secret references in place: second=(%s,%s) third=(%s,%s)", secondMachine, secondSession, thirdMachine, thirdSession)
}
for _, reference := range []string{thirdMachine, thirdSession} {
if _, ok := secrets.values[reference]; !ok {
t.Fatalf("active identity Secret %q was deleted", reference)
}
if _, queued := repository.pendingSecretCleanups[reference]; queued {
t.Fatalf("active identity Secret %q remained in cleanup queue", reference)
}
}
for _, reference := range []string{firstMachine, firstSession, secondMachine, secondSession} {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("superseded identity Secret %q was not queued for cleanup", reference)
}
}
}
func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) {
repository := &pairingRepositoryFake{exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
@ -177,3 +574,307 @@ func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) {
t.Fatal("expired exchange token remained in SecretStore")
}
}
func TestPairingPersistsOnlySafeSecurityEventFailureCategory(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com",
SessionRevocation: true, MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111",
ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
"identity-machine-revision": []byte("temporary-machine-secret-value-0000000"),
}}
remote := &onboardingRemoteFake{}
preparer := &securityEventPreparerFake{err: safeCategoryTestError{
category: "discovery_failed",
message: "sensitive-marker https://internal.example/ssf token-value",
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
failed, err := service.Continue(context.Background(), "pairing")
if err == nil {
t.Fatal("security event discovery failure was ignored")
}
if failed.Status != PairingCredentialsSaved || failed.LastErrorCategory != "security_event_discovery_failed" {
t.Fatalf("unsafe or missing pairing failure state: %#v", failed)
}
encoded, marshalErr := json.Marshal(failed)
if marshalErr != nil {
t.Fatal(marshalErr)
}
if strings.Contains(string(encoded), "sensitive-marker") || strings.Contains(string(encoded), "internal.example") || strings.Contains(string(encoded), "token-value") {
t.Fatalf("raw security event error escaped through pairing JSON: %s", encoded)
}
preparer.err = nil
completed, err := service.Continue(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if completed.Status != PairingCompleted || completed.LastErrorCategory != "" {
t.Fatalf("successful retry did not clear the diagnostic category: %#v", completed)
}
}
func TestPairingCancellationPersistsIntentBeforeCleaningTemporaryResources(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision", SessionEncryptionKeyRef: "identity-session-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
Status: PairingCredentialsSaved, CleanupStatus: PairingCleanupNone, RemoteVersion: 4,
ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
"identity-machine-revision": []byte("temporary-machine-secret-value-0000000"),
"identity-session-revision": []byte("temporary-session-secret-value-0000000"),
}}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) {
t.Fatal("cancellation must not call the onboarding remote")
return nil, nil
}, preparer)
cancelled, err := service.Cancel(context.Background(), "pairing", 7, "trace-cancel", "audit-cancel")
if err != nil {
t.Fatal(err)
}
if cancelled.Status != PairingCancelled || cancelled.CleanupStatus != PairingCleanupPending || cancelled.CancelledAt == nil {
t.Fatalf("cancellation intent was not persisted: %#v", cancelled)
}
if repository.revision.State != RevisionFailed || repository.revision.LastErrorCategory != "pairing_cancelled" {
t.Fatalf("draft was not atomically retired: %#v", repository.revision)
}
if len(secrets.values) != 3 {
t.Fatal("synchronous cancellation removed resources before the cleanup worker could resume them")
}
replayed, err := service.Cancel(context.Background(), "pairing", 7, "trace-replay", "audit-replay")
if err != nil || replayed.Version != cancelled.Version || replayed.Status != PairingCancelled {
t.Fatalf("lost cancellation response could not be replayed safely: %#v err=%v", replayed, err)
}
cleaned, err := service.Cleanup(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if cleaned.CleanupStatus != PairingCleanupCompleted || cleaned.CleanupCompletedAt == nil || !preparer.cleanupCalled {
t.Fatalf("pairing cleanup was not completed: %#v", cleaned)
}
if len(secrets.values) != 0 || repository.revision.MachineCredentialRef != "" || repository.revision.SessionEncryptionKeyRef != "" {
t.Fatalf("temporary secret references survived cleanup: secrets=%d revision=%#v", len(secrets.values), repository.revision)
}
}
func TestPairingCleanupRetriesWithSafeCategory(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionFailed, Version: 4, SessionRevocation: true},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", Status: PairingCancelled, CleanupStatus: PairingCleanupPending,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 8,
},
}
preparer := &securityEventPreparerFake{cleanupErr: safeCategoryTestError{
category: "retirement_pending", message: "sensitive cleanup marker token-value",
}}
service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{}}, nil, preparer)
pending, err := service.Cleanup(context.Background(), "pairing")
if err == nil {
t.Fatal("pending security event retirement was treated as completed")
}
if pending.CleanupStatus != PairingCleanupPending || pending.LastErrorCategory != "cleanup_security_event_retirement_pending" || strings.Contains(pending.LastErrorCategory, "sensitive") {
t.Fatalf("cleanup failure was not safely persisted: %#v", pending)
}
preparer.cleanupErr = nil
completed, err := service.Cleanup(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if completed.CleanupStatus != PairingCleanupCompleted {
t.Fatalf("cleanup retry did not complete: %#v", completed)
}
}
func TestPairingMapsUntrustedRemoteErrorCategoryToStableValue(t *testing.T) {
repository := &pairingRepositoryFake{exchange: PairingExchange{ID: "pairing", Version: 2}}
service := NewPairingService(repository, &secretStoreFake{}, nil, nil)
updated, err := service.updateFromRemote(context.Background(), repository.exchange, Exchange{
Status: ExchangeFailed, Version: 3, LastErrorCategory: "secret_token_abcdef",
})
if err != nil {
t.Fatal(err)
}
if updated.Status != PairingFailed || updated.LastErrorCategory != "remote_exchange_failed" {
t.Fatalf("untrusted remote category was persisted: %#v", updated)
}
}
func TestPairingConflictRetirementIsScopedToCurrentBlockedPairing(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", Status: PairingCredentialsSaved,
CleanupStatus: PairingCleanupNone, Version: 7, LastErrorCategory: "security_event_connection_conflict",
},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, &secretStoreFake{}, nil, preparer)
resolved, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7)
if err != nil {
t.Fatal(err)
}
if resolved.ID != "pairing" || resolved.LastErrorCategory != "security_event_retirement_pending" || !preparer.conflictRetireCalled || preparer.conflictRevisionID != "revision" {
t.Fatalf("conflict retirement escaped pairing scope: pairing=%#v preparer=%#v", resolved, preparer)
}
preparer.conflictRetireCalled = false
if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 6); !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("stale pairing version error=%v", err)
}
repository.exchange.LastErrorCategory = ""
if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7); !errors.Is(err, ErrPairingConflictNotResolvable) {
t.Fatalf("resolved pairing could retire another connection: %v", err)
}
if preparer.conflictRetireCalled {
t.Fatal("stale conflict action reached the security event manager")
}
}
func TestCompletedPairingRestoresPreparedReceiverAfterRestart(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionValidated, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-machine-revision": []byte("machine-secret-value-restored-from-store"),
}}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, nil, preparer)
if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); err != nil {
t.Fatal(err)
}
if !preparer.called {
t.Fatal("completed pairing did not reconstruct its prepared security event receiver")
}
}
func TestCompletedPairingDoesNotRestoreReceiverForSupersededRevision(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionSuperseded, Version: 5, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{
"identity-machine-revision": []byte("must-not-be-read"),
}}, nil, preparer)
if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("restore error=%v, want revision conflict", err)
}
if preparer.called {
t.Fatal("superseded Revision reconstructed an orphan prepared Receiver")
}
}
func TestCancelledPairingCleanupWaitsForCompletedReceiverRestore(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionValidated, Version: 3, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
Status: PairingCompleted, CleanupStatus: PairingCleanupNone, Version: 8,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("exchange-token"),
"identity-machine-revision": []byte("machine-secret"),
}}
preparer := &blockingSecurityEventPreparer{
prepareStarted: make(chan struct{}),
releasePrepare: make(chan struct{}),
cleanupStarted: make(chan struct{}),
}
service := NewPairingService(repository, secrets, nil, preparer)
restoreResult := make(chan error, 1)
go func() { restoreResult <- service.RestoreCompletedSecurityEvents(context.Background(), "pairing") }()
<-preparer.prepareStarted
cancelled, err := service.Cancel(context.Background(), "pairing", 8, "trace", "audit")
if err != nil {
t.Fatal(err)
}
cleanupResult := make(chan PairingExchange, 1)
cleanupError := make(chan error, 1)
go func() {
cleaned, cleanupErr := service.Cleanup(context.Background(), "pairing")
cleanupResult <- cleaned
cleanupError <- cleanupErr
}()
select {
case <-preparer.cleanupStarted:
t.Fatal("cleanup overtook an in-flight completed receiver restore")
case <-time.After(100 * time.Millisecond):
}
close(preparer.releasePrepare)
if err := <-restoreResult; !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("restore error = %v, want revision conflict after cancellation", err)
}
if err := <-cleanupError; err != nil {
t.Fatal(err)
}
cleaned := <-cleanupResult
if cancelled.Status != PairingCancelled || cleaned.CleanupStatus != PairingCleanupCompleted {
t.Fatalf("cancel/cleanup did not converge: cancelled=%#v cleaned=%#v", cancelled, cleaned)
}
if len(secrets.values) != 0 {
t.Fatalf("cancelled restore left temporary secrets: %d", len(secrets.values))
}
}
func TestPairingClassifiesExchangeCompletionFailureSeparatelyFromSSF(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com"},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111",
ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
}}
remote := &onboardingRemoteFake{completeErr: errors.New("upstream body contains sensitive marker")}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
failed, err := service.Continue(context.Background(), "pairing")
if err == nil {
t.Fatal("exchange completion failure was ignored")
}
if failed.LastErrorCategory != "exchange_completion_failed" {
t.Fatalf("completion failure was misclassified: %#v", failed)
}
}

View File

@ -3,8 +3,10 @@ package identityruntime
import (
"context"
"errors"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
@ -24,10 +26,23 @@ type RuntimeBuilderConfig struct {
}
type preparedSecurityRuntime struct {
manager *securityevents.ConnectionManager
cancel context.CancelFunc
manager *securityevents.ConnectionManager
cancel context.CancelFunc
receiverReady bool
blockedCategory string
}
type safeRuntimeError struct {
category string
cause error
}
func (err safeRuntimeError) Error() string {
return "identity runtime operation failed: " + err.category
}
func (err safeRuntimeError) Unwrap() error { return err.cause }
func (err safeRuntimeError) SafeErrorCategory() string { return err.category }
type RuntimeBuilder struct {
ctx context.Context
store *store.Store
@ -54,7 +69,36 @@ func NewRuntimeBuilder(ctx context.Context, data *store.Store, secrets PairingSe
return &RuntimeBuilder{ctx: ctx, store: data, secrets: secrets, config: config, metrics: metrics, prepared: map[string]preparedSecurityRuntime{}}
}
func (builder *RuntimeBuilder) PreparedSecurityEventReceiver() http.Handler {
builder.mutex.Lock()
defer builder.mutex.Unlock()
if len(builder.prepared) != 1 {
return nil
}
for _, prepared := range builder.prepared {
if prepared.receiverReady {
return prepared.manager
}
}
return nil
}
func (builder *RuntimeBuilder) PreparedSecurityEventManager() *securityevents.ConnectionManager {
builder.mutex.Lock()
defer builder.mutex.Unlock()
if len(builder.prepared) != 1 {
return nil
}
for _, prepared := range builder.prepared {
return prepared.manager
}
return nil
}
func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revision) (*Runtime, error) {
if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil {
return nil, err
}
if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || revision.TenantID == "" ||
revision.Audience == "" || revision.RolePrefix == "" {
return nil, errors.New("identity runtime configuration is incomplete")
@ -69,23 +113,28 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
var securityManager *securityevents.ConnectionManager
if revision.SessionRevocation {
prepared := builder.takePreparedSecurityRuntime(revision.ID)
prepared := builder.preparedSecurityRuntime(revision.ID)
securityManager = prepared.manager
if prepared.cancel != nil {
runtime.close = func() {
if securityManager != nil {
if err := securityManager.ValidateConfiguredConnectionBinding(ctx); err != nil {
cancel()
prepared.cancel()
return nil, err
}
if !prepared.receiverReady {
cancel()
return nil, safeRuntimeError{category: "security_event_not_ready", cause: store.ErrSecurityEventConnectionConflict}
}
}
if securityManager == nil {
var err error
securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision)
securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision, true)
if err != nil {
cancel()
return nil, err
}
}
runtime.SecurityEvents = securityManager
runtime.securityEventDisconnector = securityManager
}
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
@ -100,6 +149,7 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
return revision.MachineClientID, secret, err
}
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
AppEnv: builder.config.AppEnv,
Issuer: revision.Issuer, Audience: revision.Audience, TenantID: revision.TenantID,
RolePrefix: revision.RolePrefix, RequiredScopes: append([]string(nil), revision.Scopes...),
JWKSCacheTTL: builder.config.JWKSCacheTTL, IntrospectionEnabled: revision.TokenIntrospection,
@ -134,6 +184,7 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
return nil, err
}
client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{
AppEnv: builder.config.AppEnv,
Issuer: revision.Issuer, ClientID: revision.BrowserClientID,
RedirectURI: revision.PublicBaseURL + "/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: revision.WebBaseURL + "/", Scopes: append([]string{"openid", "profile"}, revision.Scopes...),
@ -161,46 +212,205 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
}
func (builder *RuntimeBuilder) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, managementSecret []byte) error {
if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.MachineClientID == "" {
if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil {
return safeRuntimeError{category: "configuration_invalid", cause: err}
}
if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.SecurityEventAudience == "" || revision.MachineClientID == "" {
return errors.New("security event configuration is incomplete")
}
if prepared := builder.preparedSecurityRuntime(revision.ID); prepared.manager != nil && prepared.blockedCategory != "" {
view, err := prepared.manager.Get(ctx)
if err == nil {
category := prepared.blockedCategory
if view.LifecycleStatus == "retiring" || view.LifecycleStatus == "disconnect_pending" {
category = "retirement_pending"
}
return safeRuntimeError{category: category, cause: store.ErrSecurityEventConnectionConflict}
}
if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return safeSecurityEventRuntimeError(err)
}
builder.removePreparedSecurityRuntime(revision.ID, prepared)
}
runtimeCtx, cancel := context.WithCancel(builder.ctx)
manager, err := builder.newSecurityEventManager(runtimeCtx, revision)
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
if err != nil {
cancel()
return err
return safeRuntimeError{category: "configuration_invalid", cause: err}
}
secretCopy := append([]byte(nil), managementSecret...)
_, err = manager.Connect(ctx, revision.SecurityEventIssuer, revision.MachineClientID, secretCopy, "identity-pairing-ssf-"+revision.ID)
clear(secretCopy)
if err != nil {
// A conflicting persisted connection is itself the resource an
// administrator must safely retire. Keep this manager reachable by the
// recovery API even when there is no Active identity Runtime yet.
if errors.Is(err, store.ErrSecurityEventConnectionConflict) {
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{
manager: manager, cancel: cancel, blockedCategory: safeSecurityEventCategory(err, "connection_conflict"),
})
return safeSecurityEventRuntimeError(err)
}
cancel()
return err
return safeSecurityEventRuntimeError(err)
}
if err := manager.ValidateConfiguredConnectionBinding(ctx); err != nil {
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{
manager: manager, cancel: cancel, blockedCategory: safeSecurityEventCategory(err, "connection_binding_mismatch"),
})
return safeSecurityEventRuntimeError(err)
}
builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{manager: manager, cancel: cancel, receiverReady: true})
return nil
}
func (builder *RuntimeBuilder) removePreparedSecurityRuntime(revisionID string, prepared preparedSecurityRuntime) {
builder.mutex.Lock()
previous := builder.prepared[revision.ID]
builder.prepared[revision.ID] = preparedSecurityRuntime{manager: manager, cancel: cancel}
current := builder.prepared[revisionID]
if current.manager == prepared.manager {
delete(builder.prepared, revisionID)
}
builder.mutex.Unlock()
if prepared.cancel != nil {
prepared.cancel()
}
}
func (builder *RuntimeBuilder) replacePreparedSecurityRuntime(revisionID string, replacement preparedSecurityRuntime) {
builder.mutex.Lock()
previous := builder.prepared[revisionID]
builder.prepared[revisionID] = replacement
builder.mutex.Unlock()
if previous.cancel != nil {
previous.cancel()
}
}
func (builder *RuntimeBuilder) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error {
builder.mutex.Lock()
prepared, exists := builder.prepared[revision.ID]
builder.mutex.Unlock()
if !exists {
runtimeCtx, cancel := context.WithCancel(builder.ctx)
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
if err != nil {
cancel()
return safeRuntimeError{category: "configuration_invalid", cause: err}
}
prepared = preparedSecurityRuntime{manager: manager, cancel: cancel}
}
err := prepared.manager.DiscardPreparedConnection(ctx, "identity-pairing-ssf-"+revision.ID)
if err != nil {
builder.mutex.Lock()
if _, alreadyStored := builder.prepared[revision.ID]; !alreadyStored {
builder.prepared[revision.ID] = prepared
}
builder.mutex.Unlock()
return safeSecurityEventRuntimeError(err)
}
builder.mutex.Lock()
current := builder.prepared[revision.ID]
if current.manager == prepared.manager {
delete(builder.prepared, revision.ID)
}
builder.mutex.Unlock()
if prepared.cancel != nil {
prepared.cancel()
}
return nil
}
func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision) (*securityevents.ConnectionManager, error) {
return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, securityevents.ConnectionManagerConfig{
AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID,
ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL,
HeartbeatInterval: builder.config.HeartbeatInterval, StaleAfter: builder.config.StaleAfter, ClockSkew: builder.config.ClockSkew,
}, builder.metrics)
func (builder *RuntimeBuilder) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error {
prepared := builder.preparedSecurityRuntime(revision.ID)
if prepared.manager == nil {
runtimeCtx, cancel := context.WithCancel(builder.ctx)
manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false)
if err != nil {
cancel()
return safeRuntimeError{category: "configuration_invalid", cause: err}
}
prepared = preparedSecurityRuntime{manager: manager, cancel: cancel}
builder.replacePreparedSecurityRuntime(revision.ID, prepared)
}
if err := prepared.manager.RetireConflictingConnection(ctx, "identity-pairing-ssf-"+revision.ID); err != nil {
return safeSecurityEventRuntimeError(err)
}
return nil
}
func (builder *RuntimeBuilder) takePreparedSecurityRuntime(revisionID string) preparedSecurityRuntime {
// RecoverSecurityEventDisconnector reconstructs only the SSF management
// surface needed to disable a fail-closed Active Revision. It deliberately
// avoids OIDC discovery, JIT, BFF Session and tenant Runtime construction.
func (builder *RuntimeBuilder) RecoverSecurityEventDisconnector(_ context.Context, revision identity.Revision) (SecurityEventDisconnector, error) {
prepared := builder.preparedSecurityRuntime(revision.ID)
if prepared.manager != nil {
if err := prepared.manager.ValidateConfiguredConnectionBinding(builder.ctx); err != nil {
return nil, err
}
return prepared.manager, nil
}
manager, err := builder.newSecurityEventManager(builder.ctx, revision, true)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return manager, nil
}
// AdoptPreparedSecurityEvents transfers the prepared manager lifetime to an
// activated Runtime. Validation only peeks at prepared state, so Verification
// callbacks remain available between validation and activation.
func (builder *RuntimeBuilder) AdoptPreparedSecurityEvents(revisionID string) context.CancelFunc {
builder.mutex.Lock()
defer builder.mutex.Unlock()
prepared := builder.prepared[revisionID]
delete(builder.prepared, revisionID)
return prepared
return prepared.cancel
}
func safeSecurityEventRuntimeError(err error) error {
var categorized interface{ SafeErrorCategory() string }
if errors.As(err, &categorized) {
return err
}
if errors.Is(err, store.ErrSecurityEventConnectionConflict) {
return safeRuntimeError{category: "connection_conflict", cause: err}
}
return safeRuntimeError{category: "preparation_failed", cause: err}
}
func safeSecurityEventCategory(err error, fallback string) string {
var categorized interface{ SafeErrorCategory() string }
if errors.As(err, &categorized) && categorized.SafeErrorCategory() != "" {
return categorized.SafeErrorCategory()
}
return fallback
}
func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision, strictRevisionBinding bool) (*securityevents.ConnectionManager, error) {
return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, builder.securityEventManagerConfig(revision, strictRevisionBinding), builder.metrics)
}
func (builder *RuntimeBuilder) securityEventManagerConfig(revision identity.Revision, strictRevisionBinding bool) securityevents.ConnectionManagerConfig {
return securityevents.ConnectionManagerConfig{
AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID,
ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL,
ExpectedTransmitterIssuer: strings.TrimRight(strings.TrimSpace(revision.SecurityEventIssuer), "/"),
ExpectedAudience: revision.SecurityEventAudience,
ExpectedOwnerKey: "identity-pairing-ssf-" + revision.ID,
StrictRevisionBinding: strictRevisionBinding,
HeartbeatInterval: builder.config.HeartbeatInterval,
StaleAfter: builder.config.StaleAfter,
ClockSkew: builder.config.ClockSkew,
}
}
func (builder *RuntimeBuilder) preparedSecurityRuntime(revisionID string) preparedSecurityRuntime {
builder.mutex.Lock()
defer builder.mutex.Unlock()
return builder.prepared[revisionID]
}
func secureCookieFor(baseURL string) bool {

View File

@ -0,0 +1,81 @@
package identityruntime
import (
"context"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
)
func TestRuntimeBuilderRejectsLoopbackHTTPOutsideLocalEnvironments(t *testing.T) {
revision := identity.Revision{
AuthCenterURL: "https://auth.example.com", Issuer: "https://auth.example.com/issuer/easyai",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com",
SecurityEventIssuer: "https://auth.example.com/ssf", SecurityEventConfigURL: "https://auth.example.com/.well-known/ssf-configuration/ssf",
SessionRevocation: true,
}
production := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "production"}}
for _, test := range []struct {
name string
mutate func(*identity.Revision)
}{
{name: "auth center", mutate: func(value *identity.Revision) { value.AuthCenterURL = "http://localhost:18000" }},
{name: "OIDC issuer", mutate: func(value *identity.Revision) { value.Issuer = "http://localhost:18003/issuer/easyai" }},
{name: "public base", mutate: func(value *identity.Revision) { value.PublicBaseURL = "http://127.0.0.1:18089" }},
{name: "web base", mutate: func(value *identity.Revision) { value.WebBaseURL = "http://localhost:5178" }},
{name: "SSF issuer", mutate: func(value *identity.Revision) { value.SecurityEventIssuer = "http://localhost:18004/ssf" }},
{name: "SSF configuration", mutate: func(value *identity.Revision) {
value.SecurityEventConfigURL = "http://localhost:18004/.well-known/ssf-configuration/ssf"
}},
} {
t.Run(test.name, func(t *testing.T) {
candidate := revision
test.mutate(&candidate)
if _, err := production.Build(context.Background(), candidate); err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("production runtime did not reject loopback HTTP %s URL: %v", test.name, err)
}
})
}
localRevision := revision
localRevision.AuthCenterURL = "http://localhost:18000"
localRevision.Issuer = "http://localhost:18003/issuer/easyai"
localRevision.PublicBaseURL = "http://127.0.0.1:18089"
localRevision.WebBaseURL = "http://localhost:5178"
localRevision.SecurityEventIssuer = "http://localhost:18004/ssf"
localRevision.SecurityEventConfigURL = "http://localhost:18004/.well-known/ssf-configuration/ssf"
development := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "development"}}
if _, err := development.Build(context.Background(), localRevision); err == nil || strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("development runtime did not pass URL policy before completeness checks: %v", err)
}
}
func TestSecurityEventManagerConfigBindsRuntimeToTargetRevision(t *testing.T) {
builder := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "test"}}
revision := identity.Revision{
ID: "revision-target",
Issuer: "https://auth.example/issuer",
TenantID: "tenant-public-id",
MachineClientID: "gateway-machine",
PublicBaseURL: "https://gateway.example",
SecurityEventIssuer: "https://auth.example/ssf/",
SecurityEventAudience: "urn:easyai:ssf:receiver:target",
}
runtimeConfig := builder.securityEventManagerConfig(revision, true)
if !runtimeConfig.StrictRevisionBinding ||
runtimeConfig.ExpectedTransmitterIssuer != "https://auth.example/ssf" ||
runtimeConfig.ExpectedAudience != revision.SecurityEventAudience ||
runtimeConfig.ManagementClientID != revision.MachineClientID ||
runtimeConfig.ExpectedOwnerKey != "identity-pairing-ssf-revision-target" {
t.Fatalf("runtime SSF binding config=%#v", runtimeConfig)
}
recoveryConfig := builder.securityEventManagerConfig(revision, false)
if recoveryConfig.StrictRevisionBinding {
t.Fatal("conflict recovery was incorrectly blocked by strict Revision binding")
}
if recoveryConfig.ExpectedOwnerKey != runtimeConfig.ExpectedOwnerKey {
t.Fatal("recovery manager lost the target owner needed for explicit validation")
}
}

View File

@ -3,6 +3,8 @@ package identityruntime
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
@ -27,15 +29,20 @@ type Builder interface {
Build(context.Context, identity.Revision) (*Runtime, error)
}
type SecurityEventDisconnector interface {
Disconnect(context.Context) (securityevents.ConnectionView, error)
}
type Runtime struct {
Revision identity.Revision
Verifier *auth.OIDCVerifier
PublicClient *auth.OIDCPublicClient
Sessions *oidcsession.Service
SessionCipher *oidcsession.Cipher
SecurityEvents *securityevents.ConnectionManager
CookieSecure bool
close func()
Revision identity.Revision
Verifier *auth.OIDCVerifier
PublicClient *auth.OIDCPublicClient
Sessions *oidcsession.Service
SessionCipher *oidcsession.Cipher
SecurityEvents *securityevents.ConnectionManager
CookieSecure bool
close func()
securityEventDisconnector SecurityEventDisconnector
}
func (runtime *Runtime) Close() {
@ -45,36 +52,126 @@ func (runtime *Runtime) Close() {
}
type Manager struct {
repository Repository
builder Builder
operation sync.Mutex
current atomic.Pointer[Runtime]
repository Repository
builder Builder
operation sync.Mutex
current atomic.Pointer[Runtime]
reconciliationRequired atomic.Bool
legacyJWTAllowed atomic.Bool
trustedWebBaseURL atomic.Pointer[string]
}
const identityRuntimeReconciliationTimeout = 5 * time.Second
func NewManager(repository Repository, builder Builder) *Manager {
return &Manager{repository: repository, builder: builder}
manager := &Manager{repository: repository, builder: builder}
manager.legacyJWTAllowed.Store(true)
return manager
}
func (manager *Manager) Current() *Runtime {
return manager.current.Load()
}
func (manager *Manager) ReconciliationRequired() bool {
return manager.reconciliationRequired.Load()
}
func (manager *Manager) LegacyJWTEnabled() bool {
return manager.legacyJWTAllowed.Load()
}
// TrustedWebBaseURL is the persisted Active Revision's exact browser origin.
// It intentionally survives a fail-closed Runtime build so the local
// break-glass manager can still repair or disable a broken Active Revision.
func (manager *Manager) TrustedWebBaseURL() string {
value := manager.trustedWebBaseURL.Load()
if value == nil {
return ""
}
return *value
}
// SecurityEventReceiver resolves the request-time receiver. During first
// onboarding there is no Active Runtime yet, so the builder-owned prepared
// receiver must remain reachable for SSF Verification callbacks.
func (manager *Manager) SecurityEventReceiver() http.Handler {
provider, ok := manager.builder.(interface{ PreparedSecurityEventReceiver() http.Handler })
if ok {
if prepared := provider.PreparedSecurityEventReceiver(); prepared != nil {
return prepared
}
}
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
return runtime.SecurityEvents
}
return manager.SecurityEventManager()
}
// SecurityEventManager resolves the manager used by administrative recovery
// operations. A prepared manager may represent an older persisted connection
// that must be retired before the first identity Runtime can be activated.
func (manager *Manager) SecurityEventManager() *securityevents.ConnectionManager {
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
return runtime.SecurityEvents
}
managerProvider, ok := manager.builder.(interface {
PreparedSecurityEventManager() *securityevents.ConnectionManager
})
if ok {
return managerProvider.PreparedSecurityEventManager()
}
return nil
}
func (manager *Manager) LoadActive(ctx context.Context) error {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return nil
}
if err != nil {
manager.failClosedRuntime()
return err
}
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
runtime, err := manager.builder.Build(ctx, revision)
if err != nil {
manager.failClosedRuntime()
return err
}
manager.publishRuntime(runtime, revision)
return nil
}
// ReconcileActive restores a fail-closed Runtime after an uncertain mutation
// outcome. It is safe to call periodically: a matching Active Runtime is left
// untouched, while a changed or missing Active Revision is published atomically.
func (manager *Manager) ReconcileActive(ctx context.Context) error {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return nil
}
if err != nil {
manager.reconciliationRequired.Store(true)
return err
}
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
if current := manager.current.Load(); current != nil && current.Revision.ID == revision.ID && current.Revision.Version == revision.Version {
manager.reconciliationRequired.Store(false)
return nil
}
runtime, err := manager.builder.Build(ctx, revision)
if err != nil {
manager.reconciliationRequired.Store(true)
return err
}
runtime.Revision = revision
manager.current.Store(runtime)
manager.publishRuntime(runtime, revision)
return nil
}
@ -85,7 +182,7 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion
if err != nil {
return identity.Revision{}, err
}
if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded && revision.State != identity.RevisionActive {
if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionActive {
return identity.Revision{}, identity.ErrRevisionConflict
}
candidate, buildErr := manager.builder.Build(ctx, revision)
@ -101,9 +198,7 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion
candidate.Close()
return identity.Revision{}, err
}
candidate.Revision = revalidated
old := manager.current.Swap(candidate)
retireRuntime(old)
manager.publishRuntime(candidate, revalidated)
return revalidated, nil
}
candidate.Close()
@ -126,12 +221,9 @@ func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion
}
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion, traceID, auditID)
if err != nil {
candidate.Close()
return identity.Revision{}, err
return manager.reconcileActivationMutation(ctx, id, candidate, err)
}
candidate.Revision = activated
old := manager.current.Swap(candidate)
retireRuntime(old)
manager.publishRuntime(candidate, activated)
return activated, nil
}
@ -145,46 +237,235 @@ func (manager *Manager) Rollback(ctx context.Context, id string, expectedVersion
if revision.Version != expectedVersion || revision.State != identity.RevisionSuperseded {
return identity.Revision{}, identity.ErrRevisionConflict
}
candidate, err := manager.builder.Build(ctx, revision)
if err != nil {
return identity.Revision{}, err
}
validated, err := manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID)
if err != nil {
candidate.Close()
return identity.Revision{}, err
}
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, validated.Version, traceID, auditID)
if err != nil {
candidate.Close()
return identity.Revision{}, err
}
candidate.Revision = activated
old := manager.current.Swap(candidate)
retireRuntime(old)
return activated, nil
// Auth Center currently reuses and mutates OAuth/SSF resources. An older
// local Revision therefore cannot prove that its remote redirect URIs,
// scopes, audiences, or credentials still match. Require a fresh onboarding
// exchange until a versioned remote-resource handoff exists.
return identity.Revision{}, identity.ErrRollbackConfigurationHandoffRequired
}
func (manager *Manager) Disable(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID)
if err != nil {
if err := manager.retireActiveSecurityEventsBeforeDisable(ctx, expectedVersion); err != nil {
return identity.Revision{}, err
}
old := manager.current.Swap(nil)
retireRuntime(old)
disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID)
if err != nil {
return manager.reconcileDisableMutation(ctx, expectedVersion, err)
}
manager.publishDisabledRuntime()
return disabled, nil
}
func (manager *Manager) retireActiveSecurityEventsBeforeDisable(ctx context.Context, expectedVersion int64) error {
active, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if err != nil {
return err
}
if active.Version != expectedVersion {
return identity.ErrRevisionConflict
}
if !active.SessionRevocation {
return nil
}
runtime := manager.current.Load()
var disconnector SecurityEventDisconnector
if runtime != nil && runtime.Revision.ID == active.ID {
disconnector = runtime.securityEventDisconnector
if disconnector == nil && runtime.SecurityEvents != nil {
disconnector = runtime.SecurityEvents
}
}
if disconnector == nil {
recoverer, ok := manager.builder.(interface {
RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error)
})
if !ok {
return identity.ErrSecurityEventRetirementPending
}
var recoverErr error
disconnector, recoverErr = recoverer.RecoverSecurityEventDisconnector(ctx, active)
if recoverErr != nil {
return recoverErr
}
// A missing local connection means there is no bound local Stream or
// credential left to retire. The already-required audit record makes
// this fail-closed recovery decision traceable.
if disconnector == nil {
return nil
}
}
connection, err := disconnector.Disconnect(ctx)
if err != nil {
return err
}
if connection.LifecycleStatus != "retiring" {
return identity.ErrSecurityEventRetirementPending
}
return nil
}
func (manager *Manager) reconcileActivationMutation(ctx context.Context, targetID string, candidate *Runtime, mutationErr error) (identity.Revision, error) {
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx)
if err == nil && active.ID == targetID && active.State == identity.RevisionActive {
manager.publishRuntime(candidate, active)
return active, nil
}
candidate.Close()
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return identity.Revision{}, mutationErr
}
if err != nil {
manager.failClosedRuntime()
return identity.Revision{}, mutationErr
}
current := manager.current.Load()
if current == nil || current.Revision.ID != active.ID {
manager.failClosedRuntime()
}
return identity.Revision{}, mutationErr
}
func (manager *Manager) reconcileRollbackValidation(ctx context.Context, targetID string, expectedVersion int64, mutationErr error) (identity.Revision, error) {
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
revision, err := manager.repository.IdentityConfigurationRevision(reconcileCtx, targetID)
if err == nil && revision.State == identity.RevisionValidated && revision.Version == expectedVersion+1 {
return revision, nil
}
return identity.Revision{}, mutationErr
}
func (manager *Manager) reconcileDisableMutation(ctx context.Context, expectedVersion int64, mutationErr error) (identity.Revision, error) {
previous := manager.current.Load()
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx)
if err == nil {
if previous == nil || active.ID != previous.Revision.ID {
manager.failClosedRuntime()
}
return identity.Revision{}, mutationErr
}
if !errors.Is(err, identity.ErrRevisionNotFound) {
manager.failClosedRuntime()
return identity.Revision{}, mutationErr
}
manager.publishDisabledRuntime()
if previous == nil || previous.Revision.ID == "" {
return identity.Revision{}, mutationErr
}
disabled, lookupErr := manager.repository.IdentityConfigurationRevision(reconcileCtx, previous.Revision.ID)
if lookupErr == nil && disabled.State == identity.RevisionSuperseded && disabled.Version == expectedVersion+1 {
return disabled, nil
}
return identity.Revision{}, mutationErr
}
func identityRuntimeReconciliationContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), identityRuntimeReconciliationTimeout)
}
func (manager *Manager) publishRuntime(candidate *Runtime, revision identity.Revision) {
candidate.Revision = revision
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
manager.legacyJWTAllowed.Store(revision.LegacyJWTEnabled)
old := manager.current.Swap(candidate)
manager.adoptPreparedSecurityEvents(candidate, revision.ID)
manager.reconciliationRequired.Store(false)
retireRuntime(old)
}
func (manager *Manager) publishDisabledRuntime() {
old := manager.current.Swap(nil)
manager.trustedWebBaseURL.Store(nil)
manager.legacyJWTAllowed.Store(true)
manager.reconciliationRequired.Store(false)
retireRuntime(old)
}
func (manager *Manager) failClosedRuntime() {
manager.legacyJWTAllowed.Store(false)
manager.reconciliationRequired.Store(true)
old := manager.current.Swap(nil)
retireRuntime(old)
}
func (manager *Manager) rememberTrustedWebBaseURL(value string) {
normalized := strings.TrimSpace(value)
if normalized == "" {
manager.trustedWebBaseURL.Store(nil)
return
}
manager.trustedWebBaseURL.Store(&normalized)
}
func (manager *Manager) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, secret []byte) error {
manager.operation.Lock()
defer manager.operation.Unlock()
current, err := manager.repository.IdentityConfigurationRevision(ctx, revision.ID)
if err != nil {
return err
}
if current.State == identity.RevisionActive {
// Activation already crossed the prepared-manager adoption point. A
// stale restart restore must not insert a second Receiver afterwards.
return nil
}
if current.Version != revision.Version || current.State != identity.RevisionDraft && current.State != identity.RevisionValidated {
return identity.ErrRevisionConflict
}
preparer, ok := manager.builder.(interface {
PrepareSecurityEvents(context.Context, identity.Revision, []byte) error
})
if !ok {
return errors.New("security event runtime preparation is unavailable")
}
return preparer.PrepareSecurityEvents(ctx, revision, secret)
return preparer.PrepareSecurityEvents(ctx, current, secret)
}
func (manager *Manager) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error {
cleaner, ok := manager.builder.(interface {
CleanupPreparedSecurityEvents(context.Context, identity.Revision) error
})
if !ok {
return errors.New("security event runtime cleanup is unavailable")
}
return cleaner.CleanupPreparedSecurityEvents(ctx, revision)
}
func (manager *Manager) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error {
resolver, ok := manager.builder.(interface {
RetireConflictingSecurityEvents(context.Context, identity.Revision) error
})
if !ok {
return errors.New("security event conflict recovery is unavailable")
}
return resolver.RetireConflictingSecurityEvents(ctx, revision)
}
func (manager *Manager) adoptPreparedSecurityEvents(runtime *Runtime, revisionID string) {
adopter, ok := manager.builder.(interface {
AdoptPreparedSecurityEvents(string) context.CancelFunc
})
if !ok {
return
}
cancel := adopter.AdoptPreparedSecurityEvents(revisionID)
if cancel == nil {
return
}
closeRuntime := runtime.close
runtime.close = func() {
if closeRuntime != nil {
closeRuntime()
}
cancel()
}
}
func retireRuntime(runtime *Runtime) {

View File

@ -3,15 +3,25 @@ package identityruntime
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
)
type runtimeRepositoryFake struct {
revisions map[string]identity.Revision
active identity.Revision
activateCalled bool
revisions map[string]identity.Revision
active identity.Revision
activateCalled bool
activeLookupErr error
markValidatedErr error
markValidatedErrAfterApply bool
activateErr error
activateErrAfterApply bool
disableErr error
disableErrAfterApply bool
}
func (f *runtimeRepositoryFake) IdentityConfigurationRevision(_ context.Context, id string) (identity.Revision, error) {
@ -22,18 +32,27 @@ func (f *runtimeRepositoryFake) IdentityConfigurationRevision(_ context.Context,
return revision, nil
}
func (f *runtimeRepositoryFake) ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) {
if f.activeLookupErr != nil {
return identity.Revision{}, f.activeLookupErr
}
if f.active.ID == "" {
return identity.Revision{}, identity.ErrRevisionNotFound
}
return f.active, nil
}
func (f *runtimeRepositoryFake) MarkIdentityRevisionValidated(_ context.Context, id string, expected int64, _, _ string) (identity.Revision, error) {
if f.markValidatedErr != nil && !f.markValidatedErrAfterApply {
return identity.Revision{}, f.markValidatedErr
}
revision := f.revisions[id]
if revision.Version != expected {
return identity.Revision{}, identity.ErrRevisionConflict
}
revision.State, revision.Version = identity.RevisionValidated, revision.Version+1
f.revisions[id] = revision
if f.markValidatedErr != nil {
return identity.Revision{}, f.markValidatedErr
}
return revision, nil
}
func (f *runtimeRepositoryFake) RevalidateActiveIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, error) {
@ -56,6 +75,9 @@ func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id
}
func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, bool, error) {
f.activateCalled = true
if f.activateErr != nil && !f.activateErrAfterApply {
return identity.Revision{}, false, f.activateErr
}
revision := f.revisions[id]
if revision.Version != expected || revision.State != identity.RevisionValidated {
return identity.Revision{}, false, identity.ErrRevisionConflict
@ -67,9 +89,15 @@ func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id s
}
revision.State, revision.Version, revision.LastTraceID, revision.LastAuditID = identity.RevisionActive, revision.Version+1, traceID, auditID
f.active, f.revisions[id] = revision, revision
if f.activateErr != nil {
return identity.Revision{}, false, f.activateErr
}
return revision, true, nil
}
func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64, traceID, auditID string) (identity.Revision, error) {
if f.disableErr != nil && !f.disableErrAfterApply {
return identity.Revision{}, f.disableErr
}
if f.active.Version != expected {
return identity.Revision{}, identity.ErrRevisionConflict
}
@ -77,22 +105,87 @@ func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context,
disabled.State, disabled.Version, disabled.LastTraceID, disabled.LastAuditID = identity.RevisionSuperseded, disabled.Version+1, traceID, auditID
f.revisions[disabled.ID] = disabled
f.active = identity.Revision{}
if f.disableErr != nil {
return identity.Revision{}, f.disableErr
}
return disabled, nil
}
type runtimeBuilderFake struct {
err error
builtIDs []string
err error
builtIDs []string
buildStarted chan struct{}
buildRelease chan struct{}
prepareStarted chan struct{}
prepareRelease chan struct{}
prepareCalls int
cleanupCalledFor string
adoptedRevision string
preparedCancel context.CancelFunc
preparedManager *securityevents.ConnectionManager
preparedReceiver http.Handler
recoveredSecurityEventDisconnector SecurityEventDisconnector
recoverSecurityEventErr error
}
type runtimeSecurityEventDisconnector struct {
lifecycle string
err error
calls int
}
func (f *runtimeSecurityEventDisconnector) Disconnect(context.Context) (securityevents.ConnectionView, error) {
f.calls++
return securityevents.ConnectionView{LifecycleStatus: f.lifecycle}, f.err
}
func (f *runtimeBuilderFake) Build(_ context.Context, revision identity.Revision) (*Runtime, error) {
f.builtIDs = append(f.builtIDs, revision.ID)
if f.buildStarted != nil {
close(f.buildStarted)
}
if f.buildRelease != nil {
<-f.buildRelease
}
if f.err != nil {
return nil, f.err
}
return &Runtime{Revision: revision}, nil
}
func (f *runtimeBuilderFake) PrepareSecurityEvents(_ context.Context, _ identity.Revision, _ []byte) error {
f.prepareCalls++
if f.prepareStarted != nil {
close(f.prepareStarted)
}
if f.prepareRelease != nil {
<-f.prepareRelease
}
return f.err
}
func (f *runtimeBuilderFake) CleanupPreparedSecurityEvents(_ context.Context, revision identity.Revision) error {
f.cleanupCalledFor = revision.ID
return f.err
}
func (f *runtimeBuilderFake) AdoptPreparedSecurityEvents(revisionID string) context.CancelFunc {
f.adoptedRevision = revisionID
return f.preparedCancel
}
func (f *runtimeBuilderFake) PreparedSecurityEventManager() *securityevents.ConnectionManager {
return f.preparedManager
}
func (f *runtimeBuilderFake) PreparedSecurityEventReceiver() http.Handler {
return f.preparedReceiver
}
func (f *runtimeBuilderFake) RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error) {
return f.recoveredSecurityEventDisconnector, f.recoverSecurityEventErr
}
func TestValidationFailureKeepsCurrentRuntimeAndDoesNotActivate(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
draft := identity.Revision{ID: "draft", State: identity.RevisionDraft, Version: 1}
@ -112,6 +205,39 @@ func TestValidationFailureKeepsCurrentRuntimeAndDoesNotActivate(t *testing.T) {
}
}
func TestLoadActiveMarksTransientFailureForAutomaticReconciliation(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"active": active},
active: active,
activeLookupErr: errors.New("database temporarily unavailable"),
}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
if err := manager.LoadActive(context.Background()); err == nil {
t.Fatal("transient active lookup failure was ignored")
}
if !manager.ReconciliationRequired() || manager.Current() != nil {
t.Fatal("failed startup load was not left fail-closed and retryable")
}
repository.activeLookupErr = nil
builder.err = errors.New("SecretStore temporarily unavailable")
if err := manager.ReconcileActive(context.Background()); err == nil {
t.Fatal("transient runtime build failure was ignored")
}
if !manager.ReconciliationRequired() {
t.Fatal("failed runtime build cleared automatic reconciliation")
}
builder.err = nil
if err := manager.ReconcileActive(context.Background()); err != nil {
t.Fatal(err)
}
if manager.ReconciliationRequired() || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
t.Fatalf("startup runtime was not recovered: current=%#v required=%v", manager.Current(), manager.ReconciliationRequired())
}
}
func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) {
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
@ -128,21 +254,209 @@ func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) {
}
}
func TestRollbackValidatesAndAtomicallyReplacesActiveRuntime(t *testing.T) {
func TestActivationReconcilesCommitAppliedThenResponseLost(t *testing.T) {
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"old": active, "new": candidate},
active: active,
activateErr: errors.New("commit response lost"),
activateErrAfterApply: true,
}
manager := NewManager(repository, &runtimeBuilderFake{})
manager.current.Store(&Runtime{Revision: active})
activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if activated.State != identity.RevisionActive || manager.Current() == nil || manager.Current().Revision.ID != candidate.ID {
t.Fatalf("committed activation was not reconciled: activated=%#v current=%#v", activated, manager.Current())
}
}
func TestActivationKeepsCurrentRuntimeWhenMutationWasNotApplied(t *testing.T) {
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
mutationErr := errors.New("activation rejected before commit")
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"old": active, "new": candidate},
active: active,
activateErr: mutationErr,
}
manager := NewManager(repository, &runtimeBuilderFake{})
original := &Runtime{Revision: active}
manager.current.Store(original)
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); !errors.Is(err, mutationErr) {
t.Fatalf("activation error = %v, want %v", err, mutationErr)
}
if manager.Current() != original {
t.Fatal("definitely uncommitted activation replaced the current runtime")
}
}
func TestActivationFailsClosedWhenCommitOutcomeCannotBeReconciled(t *testing.T) {
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"old": active, "new": candidate},
active: active,
activateErr: errors.New("commit outcome unknown"),
activeLookupErr: errors.New("database unavailable during reconciliation"),
}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
manager.current.Store(&Runtime{Revision: active})
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); err == nil {
t.Fatal("unknown activation outcome was reported as successful")
}
if manager.Current() != nil {
t.Fatal("unknown activation outcome did not fail OIDC closed")
}
if !manager.ReconciliationRequired() {
t.Fatal("unknown activation outcome did not request background reconciliation")
}
repository.activeLookupErr = nil
builder.err = errors.New("runtime dependency temporarily unavailable")
if err := manager.ReconcileActive(context.Background()); err == nil {
t.Fatal("runtime reconciliation unexpectedly ignored build failure")
}
if !manager.ReconciliationRequired() || manager.Current() != nil {
t.Fatal("failed runtime rebuild cleared fail-closed reconciliation state")
}
builder.err = nil
if err := manager.ReconcileActive(context.Background()); err != nil {
t.Fatal(err)
}
if manager.ReconciliationRequired() || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
t.Fatalf("runtime did not recover after reconciliation: current=%#v required=%v", manager.Current(), manager.ReconciliationRequired())
}
}
func TestRollbackRejectsOIDCOnlyRevisionBeforeRuntimeBuild(t *testing.T) {
active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5}
previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active,
}
manager := NewManager(repository, &runtimeBuilderFake{})
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
manager.current.Store(&Runtime{Revision: active})
rolledBack, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit")
if _, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired) {
t.Fatalf("rollback error=%v, want remote resource handoff required", err)
}
if len(builder.builtIDs) != 0 || repository.activateCalled || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
t.Fatalf("unsafe rollback changed runtime: built=%v activate=%t current=%#v", builder.builtIDs, repository.activateCalled, manager.Current())
}
}
func TestRollbackRejectsSupersededMachineCredentialBeforeRuntimeBuild(t *testing.T) {
active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5}
previous := identity.Revision{
ID: "previous", State: identity.RevisionSuperseded, Version: 3,
MachineCredentialRef: "identity-machine-previous", TokenIntrospection: true,
}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active,
}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
manager.current.Store(&Runtime{Revision: active})
if _, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired) {
t.Fatalf("rollback error=%v, want credential handoff required", err)
}
if len(builder.builtIDs) != 0 || repository.activateCalled {
t.Fatal("unsafe rollback reached runtime build or activation")
}
}
func TestValidateRejectsSupersededRevisionBeforeRuntimeBuild(t *testing.T) {
previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"previous": previous}}
builder := &runtimeBuilderFake{}
manager := NewManager(repository, builder)
if _, err := manager.Validate(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRevisionConflict) {
t.Fatalf("validate error=%v, want revision conflict", err)
}
if len(builder.builtIDs) != 0 || repository.revisions[previous.ID].State != identity.RevisionSuperseded {
t.Fatalf("superseded revision reached runtime validation: built=%v revision=%#v", builder.builtIDs, repository.revisions[previous.ID])
}
}
func TestDisableKeepsActiveRevisionUntilSecurityEventStreamCanRetire(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "disconnect_pending"}
manager := NewManager(repository, &runtimeBuilderFake{})
original := &Runtime{Revision: active, securityEventDisconnector: disconnector}
manager.current.Store(original)
if _, err := manager.Disable(context.Background(), active.Version, "trace", "audit"); !errors.Is(err, identity.ErrSecurityEventRetirementPending) {
t.Fatalf("disable error=%v, want security event retirement pending", err)
}
if disconnector.calls != 1 || repository.active.ID != active.ID || manager.Current() != original {
t.Fatalf("pending retirement changed Active state: calls=%d repository=%#v current=%#v", disconnector.calls, repository.active, manager.Current())
}
}
func TestDisableRecoversSSFWithoutBuildingBrokenFullIdentityRuntime(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "retiring"}
builder := &runtimeBuilderFake{
err: errors.New("OIDC discovery unavailable"),
recoveredSecurityEventDisconnector: disconnector,
}
manager := NewManager(repository, builder)
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if rolledBack.State != identity.RevisionActive || manager.Current().Revision.ID != previous.ID || repository.active.ID != previous.ID {
t.Fatalf("rollback did not replace active runtime: revision=%#v current=%#v", rolledBack, manager.Current())
if disabled.State != identity.RevisionSuperseded || disconnector.calls != 1 || len(builder.builtIDs) != 0 || manager.Current() != nil {
t.Fatalf("SSF-only recovery did not disable safely: disabled=%#v calls=%d built=%v current=%#v", disabled, disconnector.calls, builder.builtIDs, manager.Current())
}
}
func TestDisableRetiresSecurityEventStreamBeforeSupersedingActiveRevision(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "retiring"}
manager := NewManager(repository, &runtimeBuilderFake{})
manager.current.Store(&Runtime{Revision: active, securityEventDisconnector: disconnector})
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if disconnector.calls != 1 || disabled.State != identity.RevisionSuperseded || repository.active.ID != "" || manager.Current() != nil {
t.Fatalf("safe disable state: calls=%d disabled=%#v repository=%#v current=%#v", disconnector.calls, disabled, repository.active, manager.Current())
}
}
func TestDisableReconcilesCommitAppliedThenResponseLost(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
repository := &runtimeRepositoryFake{
revisions: map[string]identity.Revision{"active": active},
active: active,
disableErr: errors.New("commit response lost"),
disableErrAfterApply: true,
}
manager := NewManager(repository, &runtimeBuilderFake{})
manager.current.Store(&Runtime{Revision: active})
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
if err != nil {
t.Fatal(err)
}
if disabled.State != identity.RevisionSuperseded || manager.Current() != nil {
t.Fatalf("committed disable was not reconciled: disabled=%#v current=%#v", disabled, manager.Current())
}
}
@ -162,3 +476,192 @@ func TestActiveRevalidationSwapsOnlyAfterSuccessfulValidation(t *testing.T) {
t.Fatalf("active runtime was not safely revalidated: %#v", revalidated)
}
}
func TestLoadFailureKeepsPersistedActiveWebOriginForBreakGlassRecovery(t *testing.T) {
active := identity.Revision{
ID: "active", State: identity.RevisionActive, Version: 4,
WebBaseURL: "https://gateway.example.com",
}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
manager := NewManager(repository, &runtimeBuilderFake{err: errors.New("OIDC discovery unavailable")})
if err := manager.LoadActive(context.Background()); err == nil {
t.Fatal("active runtime load unexpectedly succeeded")
}
if manager.Current() != nil || manager.TrustedWebBaseURL() != active.WebBaseURL {
t.Fatalf("failed Active load lost recovery origin: current=%#v origin=%q", manager.Current(), manager.TrustedWebBaseURL())
}
repository.active = identity.Revision{}
if err := manager.ReconcileActive(context.Background()); err != nil {
t.Fatal(err)
}
if manager.TrustedWebBaseURL() != "" {
t.Fatalf("confirmed disable retained stale recovery origin %q", manager.TrustedWebBaseURL())
}
}
func TestActivationFinishingFirstPreventsStalePreparedSecurityEventRestore(t *testing.T) {
candidate := identity.Revision{ID: "candidate", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"candidate": candidate}}
builder := &runtimeBuilderFake{buildStarted: make(chan struct{}), buildRelease: make(chan struct{})}
manager := NewManager(repository, builder)
activationDone := make(chan error, 1)
go func() {
_, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
activationDone <- err
}()
<-builder.buildStarted
restoreDone := make(chan error, 1)
go func() {
restoreDone <- manager.PrepareSecurityEvents(context.Background(), candidate, []byte("secret"))
}()
close(builder.buildRelease)
if err := <-activationDone; err != nil {
t.Fatal(err)
}
if err := <-restoreDone; err != nil {
t.Fatal(err)
}
if builder.prepareCalls != 0 || manager.Current() == nil || manager.Current().Revision.ID != candidate.ID {
t.Fatalf("stale restore survived activation: prepare_calls=%d current=%#v", builder.prepareCalls, manager.Current())
}
}
func TestPreparedSecurityEventRestoreFinishingFirstIsAdoptedByActivation(t *testing.T) {
candidate := identity.Revision{ID: "candidate", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"candidate": candidate}}
builder := &runtimeBuilderFake{
buildStarted: make(chan struct{}), buildRelease: make(chan struct{}),
prepareStarted: make(chan struct{}), prepareRelease: make(chan struct{}),
}
manager := NewManager(repository, builder)
restoreDone := make(chan error, 1)
go func() {
restoreDone <- manager.PrepareSecurityEvents(context.Background(), candidate, []byte("secret"))
}()
<-builder.prepareStarted
activationDone := make(chan error, 1)
go func() {
_, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
activationDone <- err
}()
select {
case <-builder.buildStarted:
t.Fatal("activation did not wait for prepared Receiver restoration")
case <-time.After(50 * time.Millisecond):
}
close(builder.prepareRelease)
if err := <-restoreDone; err != nil {
t.Fatal(err)
}
<-builder.buildStarted
close(builder.buildRelease)
if err := <-activationDone; err != nil {
t.Fatal(err)
}
if builder.prepareCalls != 1 || builder.adoptedRevision != candidate.ID {
t.Fatalf("prepared restore was not adopted: prepare_calls=%d adopted=%q", builder.prepareCalls, builder.adoptedRevision)
}
}
func TestManagerDelegatesPreparedSecurityEventCleanupWithoutChangingActiveRuntime(t *testing.T) {
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
draft := identity.Revision{ID: "draft", State: identity.RevisionFailed, Version: 2}
builder := &runtimeBuilderFake{}
manager := NewManager(&runtimeRepositoryFake{}, builder)
manager.current.Store(&Runtime{Revision: active})
if err := manager.CleanupPreparedSecurityEvents(context.Background(), draft); err != nil {
t.Fatal(err)
}
if builder.cleanupCalledFor != draft.ID || manager.Current().Revision.ID != active.ID {
t.Fatalf("cleanup changed active runtime or skipped the draft: called=%q current=%#v", builder.cleanupCalledFor, manager.Current())
}
}
func TestActivationAdoptsPreparedSecurityEventLifetime(t *testing.T) {
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"new": candidate}}
preparedContext, preparedCancel := context.WithCancel(context.Background())
builder := &runtimeBuilderFake{preparedCancel: preparedCancel}
manager := NewManager(repository, builder)
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); err != nil {
t.Fatal(err)
}
if builder.adoptedRevision != candidate.ID {
t.Fatalf("prepared security events were not adopted: %q", builder.adoptedRevision)
}
manager.Current().Close()
select {
case <-preparedContext.Done():
default:
t.Fatal("closing the active runtime did not release its prepared security event manager")
}
}
func TestPreparedSecurityEventReceiverRemainsAvailableUntilAdoption(t *testing.T) {
cancelled := false
builder := &RuntimeBuilder{prepared: map[string]preparedSecurityRuntime{
"draft": {manager: &securityevents.ConnectionManager{}, cancel: func() { cancelled = true }, receiverReady: true},
}}
if builder.PreparedSecurityEventReceiver() == nil {
t.Fatal("prepared receiver was unavailable before activation")
}
cancel := builder.AdoptPreparedSecurityEvents("draft")
if cancel == nil || builder.PreparedSecurityEventReceiver() != nil {
t.Fatal("prepared receiver ownership was not transferred exactly once")
}
cancel()
if !cancelled {
t.Fatal("adopted receiver lifetime could not be released")
}
}
func TestSecurityEventManagerExposesPreparedRecoveryManagerWithoutActiveRuntime(t *testing.T) {
prepared := &securityevents.ConnectionManager{}
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared})
if manager.SecurityEventManager() != prepared || manager.SecurityEventReceiver() != prepared {
t.Fatal("prepared security event manager was unavailable to recovery handlers")
}
}
func TestSecurityEventManagerPrefersActiveRuntime(t *testing.T) {
active := &securityevents.ConnectionManager{}
prepared := &securityevents.ConnectionManager{}
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared})
manager.current.Store(&Runtime{SecurityEvents: active})
if manager.SecurityEventManager() != active {
t.Fatal("prepared recovery manager replaced the active runtime manager")
}
}
func TestSecurityEventReceiverPrefersReadyDraftDuringRepairing(t *testing.T) {
active := &securityevents.ConnectionManager{}
prepared := &securityevents.ConnectionManager{}
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared, preparedReceiver: prepared})
manager.current.Store(&Runtime{SecurityEvents: active})
if manager.SecurityEventReceiver() != prepared {
t.Fatal("verification callback was not routed to the ready draft receiver")
}
}
func TestConflictingPreparedManagerIsRecoverableButNotUsedAsReceiver(t *testing.T) {
prepared := &securityevents.ConnectionManager{}
builder := &RuntimeBuilder{prepared: map[string]preparedSecurityRuntime{
"draft": {manager: prepared},
}}
if builder.PreparedSecurityEventManager() != prepared {
t.Fatal("conflicting connection manager was unavailable for retirement")
}
if builder.PreparedSecurityEventReceiver() != nil {
t.Fatal("unprepared conflict manager was exposed as a verification receiver")
}
}

View File

@ -34,12 +34,15 @@ type ConnectionRepository interface {
CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error)
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
BindSecurityEventConnection(context.Context, string, string, string, string, int64) (store.SecurityEventConnection, error)
SetSecurityEventManagementCredential(context.Context, string, string, string) (store.SecurityEventConnection, error)
UpdateSecurityEventConnectionLifecycle(context.Context, string, string, *string) (store.SecurityEventConnection, error)
SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error)
SetSecurityEventManagementCredential(context.Context, string, string, string, int64) (store.SecurityEventConnection, error)
TransitionSecurityEventConnectionLifecycle(context.Context, string, string, int64, string, *string) (store.SecurityEventConnection, error)
SetSecurityEventNextCredential(context.Context, string, string, int64) (store.SecurityEventConnection, error)
PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error)
DeleteSecurityEventConnection(context.Context, string) error
DiscardPreparedSecurityEventConnection(context.Context, string, string, int64) error
FinalizeRetiringSecurityEventConnection(context.Context, string, int64) error
SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error)
QueueIdentitySecretCleanup(context.Context, string, time.Time) error
RemovePendingIdentitySecretCleanup(context.Context, string) (bool, error)
}
type ConnectionManagerConfig struct {
@ -49,6 +52,10 @@ type ConnectionManagerConfig struct {
OIDCTenantID string
ManagementClientID string
ManagementClientSecret string
ExpectedTransmitterIssuer string
ExpectedAudience string
ExpectedOwnerKey string
StrictRevisionBinding bool
PublicBaseURL string
HeartbeatInterval time.Duration
StaleAfter time.Duration
@ -79,15 +86,23 @@ type connectionRuntime struct {
cancel context.CancelFunc
}
type connectionRetirementAuthorization struct {
connectionID string
credentialRef string
ownerKey string
}
type ConnectionManager struct {
ctx context.Context
repository ConnectionRepository
secrets SecretStore
config ConnectionManagerConfig
metrics *Metrics
mutex sync.RWMutex
operation sync.Mutex
runtime *connectionRuntime
ctx context.Context
repository ConnectionRepository
secrets SecretStore
config ConnectionManagerConfig
metrics *Metrics
mutex sync.RWMutex
operation sync.Mutex
runtime *connectionRuntime
retirement *connectionRetirementAuthorization
bindingMismatch bool
}
type transmitterConfiguration struct {
@ -118,6 +133,17 @@ func (e remoteHTTPError) Error() string {
return fmt.Sprintf("SSF endpoint returned HTTP %d", e.status)
}
type safeConnectionError struct {
category string
cause error
}
func (err safeConnectionError) Error() string {
return "security event operation failed: " + err.category
}
func (err safeConnectionError) Unwrap() error { return err.cause }
func (err safeConnectionError) SafeErrorCategory() string { return err.category }
func NewConnectionManager(ctx context.Context, repository ConnectionRepository, secrets SecretStore, config ConnectionManagerConfig, metrics *Metrics) (*ConnectionManager, error) {
if repository == nil || secrets == nil || !config.OIDCEnabled || config.OIDCIssuer == "" || config.OIDCTenantID == "" {
return nil, errors.New("security event connection prerequisites are incomplete")
@ -138,16 +164,35 @@ func NewConnectionManager(ctx context.Context, repository ConnectionRepository,
config.CredentialOverlapDuration = 3 * time.Minute
}
if config.RetirementDuration == 0 {
config.RetirementDuration = 6 * time.Minute
config.RetirementDuration = defaultSecurityEventRetirementDuration
}
manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets, config: config, metrics: metrics}
connection, err := repository.SecurityEventConnection(ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
if config.StrictRevisionBinding {
return nil, safeConnectionError{category: "connection_binding_missing", cause: err}
}
return manager, nil
}
if err != nil {
return nil, fmt.Errorf("load security event connection: %w", err)
}
if config.StrictRevisionBinding {
if err := manager.validateConnectionBinding(connection); err != nil {
return nil, err
}
} else if hasRevisionBindingExpectation(config) {
if err := manager.validateConnectionBinding(connection); err != nil {
// A recovery manager may inspect a foreign singleton connection, but
// it must not activate it: activate() would combine the old persisted
// Secret with the new Revision's token endpoint.
if connection.LifecycleStatus == "retiring" {
go manager.finishRetirement(connection)
}
manager.bindingMismatch = true
return manager, nil
}
}
if connection.LifecycleStatus == "retiring" {
if connection.StreamID != nil && connection.Audience != nil {
_ = manager.activate(ctx, connection)
@ -165,7 +210,9 @@ func NewConnectionManager(ctx context.Context, repository ConnectionRepository,
if connection.StreamID != nil && connection.Audience != nil {
if err := manager.activate(ctx, connection); err != nil {
category := "credential_unavailable"
_, _ = repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "degraded", &category)
_, _ = repository.TransitionSecurityEventConnectionLifecycle(
ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "degraded", &category,
)
}
}
if connection.LifecycleStatus == "bootstrap" {
@ -174,6 +221,42 @@ func NewConnectionManager(ctx context.Context, repository ConnectionRepository,
return manager, nil
}
func hasRevisionBindingExpectation(config ConnectionManagerConfig) bool {
return strings.TrimSpace(config.ExpectedTransmitterIssuer) != "" ||
strings.TrimSpace(config.ExpectedAudience) != "" ||
strings.TrimSpace(config.ExpectedOwnerKey) != ""
}
// ValidateConfiguredConnectionBinding verifies that the singleton persisted
// SSF connection belongs to the identity Revision represented by this manager.
// Recovery managers call this explicitly only when they are about to become an
// Active Runtime; cleanup and retirement remain able to inspect old bindings.
func (m *ConnectionManager) ValidateConfiguredConnectionBinding(ctx context.Context) error {
connection, err := m.repository.SecurityEventConnection(ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return safeConnectionError{category: "connection_binding_missing", cause: err}
}
if err != nil {
return safeConnectionError{category: "connection_binding_unavailable", cause: err}
}
return m.validateConnectionBinding(connection)
}
func (m *ConnectionManager) validateConnectionBinding(connection store.SecurityEventConnection) error {
expectedIssuer := strings.TrimRight(strings.TrimSpace(m.config.ExpectedTransmitterIssuer), "/")
expectedAudience := strings.TrimSpace(m.config.ExpectedAudience)
expectedClientID := strings.TrimSpace(m.config.ManagementClientID)
expectedOwner := strings.TrimSpace(m.config.ExpectedOwnerKey)
if expectedIssuer == "" || expectedAudience == "" || expectedClientID == "" || expectedOwner == "" {
return safeConnectionError{category: "connection_binding_invalid", cause: errors.New("security event Revision binding is incomplete")}
}
if connection.TransmitterIssuer != expectedIssuer || connection.Audience == nil || *connection.Audience != expectedAudience ||
connection.ManagementClientID != expectedClientID || connection.IdempotencyKey != expectedOwner {
return safeConnectionError{category: "connection_binding_mismatch", cause: store.ErrSecurityEventConnectionConflict}
}
return nil
}
func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) {
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
@ -183,6 +266,9 @@ func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) {
}
func (m *ConnectionManager) IntrospectionCredential(ctx context.Context) (string, []byte, error) {
if err := m.requireOperationalBinding(); err != nil {
return "", nil, err
}
connection, err := m.repository.SecurityEventConnection(ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" {
@ -212,6 +298,10 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien
if (managementClientID == "") != (len(managementSecret) == 0) {
return ConnectionView{}, errors.New("machine client id and secret must be provided together")
}
if err == nil && (existing.LifecycleStatus == "retiring" ||
existing.LifecycleStatus == "disconnect_pending" && existing.IdempotencyKey == idempotencyKey) {
return ConnectionView{}, safeConnectionError{category: "retirement_pending", cause: store.ErrSecurityEventConnectionConflict}
}
if !credentialsProvided {
if err == nil {
managementClientID, managementSecret, _ = m.managementCredential(ctx, existing)
@ -225,10 +315,46 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien
return ConnectionView{}, err
}
if err == nil {
if m.bindingMismatch && existing.IdempotencyKey == idempotencyKey {
return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
}
if existing.TransmitterIssuer != issuer {
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
}
if credentialsProvided {
if existing.IdempotencyKey != idempotencyKey {
if existing.ManagementClientID == "" || existing.ManagementClientID != managementClientID {
return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
}
if existing.StreamID == nil {
m.retirement = &connectionRetirementAuthorization{
connectionID: existing.ConnectionID, credentialRef: valueOrEmpty(existing.ManagementCredentialRef), ownerKey: idempotencyKey,
}
return ConnectionView{}, safeConnectionError{category: "connection_conflict", cause: store.ErrSecurityEventConnectionConflict}
}
if m.retirement == nil || m.retirement.connectionID != existing.ConnectionID ||
m.retirement.credentialRef != valueOrEmpty(existing.ManagementCredentialRef) ||
m.retirement.ownerKey != idempotencyKey {
if err := m.proveManagementCredentialHandoff(ctx, existing, managementClientID, managementSecret); err != nil {
return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: err}
}
existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret)
if err != nil {
return ConnectionView{}, err
}
m.retirement = &connectionRetirementAuthorization{
connectionID: existing.ConnectionID, credentialRef: valueOrEmpty(existing.ManagementCredentialRef), ownerKey: idempotencyKey,
}
}
if existing.LifecycleStatus == "disconnect_pending" {
go m.retryDisconnect(existing.ConnectionID)
return ConnectionView{}, safeConnectionError{category: "retirement_pending", cause: store.ErrSecurityEventConnectionConflict}
}
return ConnectionView{}, safeConnectionError{category: "connection_conflict", cause: store.ErrSecurityEventConnectionConflict}
}
if existing.ManagementClientID != "" && existing.ManagementClientID != managementClientID {
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
}
existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret)
if err != nil {
return ConnectionView{}, err
@ -247,17 +373,17 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien
connectionID := uuid.NewString()
reference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
if err := m.secrets.Put(ctx, managementReference, managementSecret); err != nil {
if err := m.stageIdentitySecret(ctx, managementReference, managementSecret); err != nil {
return ConnectionView{}, err
}
secret, err := randomPushBearer()
if err != nil {
_ = m.secrets.Delete(ctx, managementReference)
_ = m.retireIdentitySecret(ctx, managementReference)
return ConnectionView{}, err
}
defer clear(secret)
if err := m.secrets.Put(ctx, reference, secret); err != nil {
_ = m.secrets.Delete(ctx, managementReference)
if err := m.stageIdentitySecret(ctx, reference, secret); err != nil {
_ = m.retireIdentitySecret(ctx, managementReference)
return ConnectionView{}, err
}
endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf"
@ -267,8 +393,9 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien
ManagementCredentialRef: managementReference, IdempotencyKey: idempotencyKey,
})
if err != nil {
_ = m.secrets.Delete(ctx, reference)
_ = m.secrets.Delete(ctx, managementReference)
// Commit outcome can be ambiguous: rollback keeps the staging rows,
// while commit atomically removes them. Requeueing here could delete
// credentials already referenced by the committed connection.
return ConnectionView{}, err
}
return m.resumeConnecting(ctx, connection)
@ -276,17 +403,14 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien
func (m *ConnectionManager) persistManagementCredential(ctx context.Context, connection store.SecurityEventConnection, clientID string, secret []byte) (store.SecurityEventConnection, error) {
reference := "ssf-management-" + connection.ConnectionID + "-" + uuid.NewString()
if err := m.secrets.Put(ctx, reference, secret); err != nil {
if err := m.stageIdentitySecret(ctx, reference, secret); err != nil {
return store.SecurityEventConnection{}, err
}
updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference)
updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference, connection.Version)
if err != nil {
_ = m.secrets.Delete(ctx, reference)
// The staging row is the source of truth when commit outcome is unknown.
return store.SecurityEventConnection{}, err
}
if connection.ManagementCredentialRef != nil {
_ = m.secrets.Delete(ctx, *connection.ManagementCredentialRef)
}
return updated, nil
}
@ -337,6 +461,9 @@ func (m *ConnectionManager) resumeConnecting(ctx context.Context, connection sto
}
func (m *ConnectionManager) Verify(ctx context.Context) (ConnectionView, error) {
if err := m.requireOperationalBinding(); err != nil {
return ConnectionView{}, err
}
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
return ConnectionView{}, err
@ -366,6 +493,9 @@ func (m *ConnectionManager) Verify(ctx context.Context) (ConnectionView, error)
func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionView, error) {
m.operation.Lock()
defer m.operation.Unlock()
if err := m.requireOperationalBinding(); err != nil {
return ConnectionView{}, err
}
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
return ConnectionView{}, err
@ -381,14 +511,14 @@ func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionVie
if err != nil {
return ConnectionView{}, err
}
if err := m.secrets.Put(ctx, nextReference, next); err != nil {
if err := m.stageIdentitySecret(ctx, nextReference, next); err != nil {
clear(next)
return ConnectionView{}, err
}
connection, err = m.repository.SetSecurityEventNextCredential(ctx, connection.ConnectionID, nextReference)
connection, err = m.repository.SetSecurityEventNextCredential(ctx, connection.ConnectionID, nextReference, connection.Version)
if err != nil {
clear(next)
_ = m.secrets.Delete(ctx, nextReference)
// Never recreate cleanup intent after an ambiguous adoption commit.
return ConnectionView{}, err
}
} else {
@ -424,10 +554,56 @@ func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionVie
func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, error) {
m.operation.Lock()
defer m.operation.Unlock()
if err := m.requireOperationalBinding(); err != nil {
return ConnectionView{}, err
}
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil {
return ConnectionView{}, err
}
return m.disconnect(ctx, connection)
}
// RetireConflictingConnection is a pairing-scoped recovery operation. It may
// retire only a connection owned by another workflow; a stale request becomes
// a no-op once the current pairing owns the singleton connection.
func (m *ConnectionManager) RetireConflictingConnection(ctx context.Context, currentOwnerKey string) error {
m.operation.Lock()
defer m.operation.Unlock()
connection, err := m.repository.SecurityEventConnection(ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return nil
}
if err != nil {
return err
}
if strings.TrimSpace(currentOwnerKey) == "" {
return errors.New("current security event owner is required")
}
if connection.IdempotencyKey == currentOwnerKey {
return nil
}
if connection.LifecycleStatus == "retiring" {
go m.finishRetirement(connection)
return nil
}
if m.retirement == nil || m.retirement.connectionID != connection.ConnectionID ||
m.retirement.credentialRef != valueOrEmpty(connection.ManagementCredentialRef) ||
m.retirement.ownerKey != currentOwnerKey {
return safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
}
if connection.LifecycleStatus == "disconnect_pending" {
go m.retryDisconnect(connection.ConnectionID)
return nil
}
_, err = m.disconnect(ctx, connection)
return err
}
func (m *ConnectionManager) disconnect(ctx context.Context, connection store.SecurityEventConnection) (ConnectionView, error) {
if connection.LifecycleStatus == "retiring" || connection.LifecycleStatus == "disconnect_pending" {
return m.view(connection), nil
}
if connection.StreamID != nil {
configuration, client, discoverErr := m.discover(ctx, connection.TransmitterIssuer)
if discoverErr == nil {
@ -441,12 +617,20 @@ func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, err
}
if discoverErr != nil {
category := "remote_disconnect_failed"
connection, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "disconnect_pending", &category)
transitioned, transitionErr := m.repository.TransitionSecurityEventConnectionLifecycle(
ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "disconnect_pending", &category,
)
if transitionErr != nil {
return ConnectionView{}, transitionErr
}
connection = transitioned
go m.retryDisconnect(connection.ConnectionID)
return m.view(connection), nil
}
}
connection, err = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "retiring", nil)
connection, err := m.repository.TransitionSecurityEventConnectionLifecycle(
ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "retiring", nil,
)
if err != nil {
return ConnectionView{}, err
}
@ -454,6 +638,51 @@ func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, err
return m.view(connection), nil
}
// DiscardPreparedConnection removes only the connection created by one identity
// pairing. Bound streams continue through the normal retirement window so
// revocation watermarks and introspection fallback are never bypassed.
func (m *ConnectionManager) DiscardPreparedConnection(ctx context.Context, ownerKey string) error {
m.operation.Lock()
defer m.operation.Unlock()
connection, err := m.repository.SecurityEventConnection(ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return nil
}
if err != nil {
return safeConnectionError{category: "connection_cleanup_failed", cause: err}
}
if strings.TrimSpace(ownerKey) == "" || connection.IdempotencyKey != ownerKey {
return nil
}
if connection.StreamID != nil {
if connection.LifecycleStatus != "retiring" && connection.LifecycleStatus != "disconnect_pending" {
if _, err := m.disconnect(ctx, connection); err != nil {
return safeConnectionError{category: "connection_cleanup_failed", cause: err}
}
}
return safeConnectionError{category: "retirement_pending", cause: errors.New("security event retirement is pending")}
}
err = m.repository.DiscardPreparedSecurityEventConnection(
ctx, connection.ConnectionID, ownerKey, connection.Version,
)
if err != nil && !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return safeConnectionError{category: "connection_cleanup_failed", cause: err}
}
m.stopRuntime()
return nil
}
func connectionSecretReferences(connection store.SecurityEventConnection) []string {
references := []string{connection.CredentialRef}
if connection.ManagementCredentialRef != nil {
references = append(references, *connection.ManagementCredentialRef)
}
if connection.NextCredentialRef != nil {
references = append(references, *connection.NextCredentialRef)
}
return references
}
func (m *ConnectionManager) retryDisconnect(connectionID string) {
delay := time.Second
for {
@ -478,7 +707,9 @@ func (m *ConnectionManager) retryDisconnect(connectionID string) {
}
}
if err == nil {
connection, err = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "retiring", nil)
connection, err = m.repository.TransitionSecurityEventConnectionLifecycle(
m.ctx, connectionID, "disconnect_pending", connection.Version, "retiring", nil,
)
if err == nil {
go m.finishRetirement(connection)
}
@ -625,49 +856,57 @@ func (m *ConnectionManager) finishAutomaticVerification(connectionID string, con
defer cancel()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
expectedLifecycle := "verifying"
if rotating {
expectedLifecycle = "rotating"
}
for {
connection, err := m.repository.SecurityEventConnection(ctx)
if err != nil || connection.ConnectionID != connectionID || connection.StreamID == nil {
if err != nil || connection.ConnectionID != connectionID || connection.StreamID == nil || connection.LifecycleStatus != expectedLifecycle {
return
}
_, verifiedAt, _ := m.repository.SecurityEventMetrics(ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience))
if verifiedAt != nil && !verifiedAt.Before(started) {
if err := m.setStreamStatus(ctx, client, configuration, token, *connection.StreamID, "enabled", "verification_succeeded"); err != nil {
category := "stream_enable_failed"
lifecycle := "verifying"
if rotating {
lifecycle = "rotating"
}
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connectionID, lifecycle, &category)
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, expectedLifecycle, connection.Version, expectedLifecycle, &category,
)
return
}
if rotating && connection.NextCredentialRef != nil {
oldReference, nextReference := connection.CredentialRef, *connection.NextCredentialRef
nextReference := *connection.NextCredentialRef
select {
case <-m.ctx.Done():
return
case <-time.After(m.config.CredentialOverlapDuration):
}
current, currentErr := m.repository.SecurityEventConnection(m.ctx)
if currentErr != nil || current.ConnectionID != connectionID || current.LifecycleStatus != "rotating" ||
current.NextCredentialRef == nil || *current.NextCredentialRef != nextReference {
return
}
connection, err = m.repository.PromoteSecurityEventCredential(m.ctx, connectionID, nextReference)
if err == nil {
_ = m.secrets.Delete(m.ctx, oldReference)
_ = m.activate(m.ctx, connection)
go m.finishBootstrap(connectionID)
}
return
}
connection, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connectionID, "bootstrap", nil)
go m.finishBootstrap(connectionID)
connection, err = m.repository.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, expectedLifecycle, connection.Version, "bootstrap", nil,
)
if err == nil {
go m.finishBootstrap(connectionID)
}
return
}
select {
case <-ctx.Done():
category := "verification_timeout"
lifecycle := "verifying"
if rotating {
lifecycle = "rotating"
}
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, lifecycle, &category)
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
m.ctx, connectionID, expectedLifecycle, connection.Version, expectedLifecycle, &category,
)
return
case <-ticker.C:
}
@ -691,46 +930,89 @@ func (m *ConnectionManager) finishBootstrap(connectionID string) {
if err == nil && connection.ConnectionID == connectionID && connection.LifecycleStatus == "bootstrap" {
mode, _, metricsErr := m.repository.SecurityEventMetrics(m.ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience))
if metricsErr == nil && mode == "push_healthy" {
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "enabled", nil)
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
m.ctx, connectionID, "bootstrap", connection.Version, "enabled", nil,
)
return
}
category := "bootstrap_verification_stale"
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "degraded", &category)
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
m.ctx, connectionID, "bootstrap", connection.Version, "degraded", &category,
)
}
}
func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConnection) {
elapsed := time.Since(connection.UpdatedAt)
delay := m.config.RetirementDuration - elapsed
if delay > 0 {
select {
case <-m.ctx.Done():
return
case <-time.After(delay):
}
}
delay = time.Second
func (m *ConnectionManager) finishRetirement(retirement store.SecurityEventConnection) {
retryDelay := time.Second
for {
if err := m.repository.DeleteSecurityEventConnection(m.ctx, connection.ConnectionID); err == nil || errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
break
}
select {
case <-m.ctx.Done():
connection, err := m.repository.SecurityEventConnection(m.ctx)
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return
case <-time.After(delay):
}
if delay < time.Minute {
delay *= 2
if err != nil {
if !waitForConnectionRetry(m.ctx, retryDelay) {
return
}
retryDelay = nextConnectionRetryDelay(retryDelay)
continue
}
if connection.ConnectionID != retirement.ConnectionID || connection.LifecycleStatus != "retiring" {
return
}
if remaining := m.config.RetirementDuration - time.Since(connection.UpdatedAt); remaining > 0 {
select {
case <-m.ctx.Done():
return
case <-time.After(remaining):
}
continue
}
m.stopRuntime()
err = m.repository.FinalizeRetiringSecurityEventConnection(m.ctx, connection.ConnectionID, connection.Version)
if err == nil || errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return
}
if !waitForConnectionRetry(m.ctx, retryDelay) {
return
}
retryDelay = nextConnectionRetryDelay(retryDelay)
}
m.stopRuntime()
_ = m.secrets.Delete(m.ctx, connection.CredentialRef)
if connection.ManagementCredentialRef != nil {
_ = m.secrets.Delete(m.ctx, *connection.ManagementCredentialRef)
}
func waitForConnectionRetry(ctx context.Context, delay time.Duration) bool {
select {
case <-ctx.Done():
return false
case <-time.After(delay):
return true
}
if connection.NextCredentialRef != nil {
_ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef)
}
func nextConnectionRetryDelay(delay time.Duration) time.Duration {
if delay < time.Minute {
return delay * 2
}
return delay
}
const identitySecretStagingTTL = 10 * time.Minute
func (m *ConnectionManager) stageIdentitySecret(ctx context.Context, reference string, value []byte) error {
if err := m.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC().Add(identitySecretStagingTTL)); err != nil {
return err
}
if err := m.secrets.Put(ctx, reference, value); err != nil {
deleteErr := m.secrets.Delete(ctx, reference)
if deleteErr == nil || errors.Is(deleteErr, ErrSecretNotFound) {
_, _ = m.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
}
return err
}
return nil
}
func (m *ConnectionManager) retireIdentitySecret(ctx context.Context, reference string) error {
return m.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC())
}
func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID string, managementSecret []byte, requirePublicBaseURL bool) error {
@ -794,6 +1076,10 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl
return "", err
}
defer clear(managementSecret)
return m.requestManagementToken(ctx, client, scopes, managementClientID, managementSecret)
}
func (m *ConnectionManager) requestManagementToken(ctx context.Context, client *http.Client, scopes, managementClientID string, managementSecret []byte) (string, error) {
form := url.Values{"grant_type": {"client_credentials"}, "scope": {scopes}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode()))
if err != nil {
@ -820,6 +1106,44 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl
return payload.AccessToken, nil
}
func (m *ConnectionManager) proveManagementCredentialHandoff(ctx context.Context, connection store.SecurityEventConnection, managementClientID string, managementSecret []byte) error {
if connection.StreamID == nil || connection.Audience == nil {
return store.ErrSecurityEventConnectionConflict
}
configuration, client, err := m.discover(ctx, connection.TransmitterIssuer)
if err != nil {
return err
}
token, err := m.requestManagementToken(ctx, client, "ssf.stream.read ssf.stream.manage", managementClientID, managementSecret)
if err != nil {
return err
}
var streams []streamConfiguration
if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil {
return err
}
for _, stream := range streams {
if stream.StreamID != *connection.StreamID {
continue
}
if err := validateCreatedStream(stream, connection.TransmitterIssuer, connection.EndpointURL); err != nil || stream.Audience != *connection.Audience {
return store.ErrSecurityEventConnectionConflict
}
return nil
}
// An authenticated empty list proves that the new credential belongs to a
// token issuer trusted by the old transmitter. Disconnect treats the absent
// Stream as an idempotent remote deletion.
return nil
}
func (m *ConnectionManager) requireOperationalBinding() error {
if m.bindingMismatch {
return safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
}
return nil
}
func (m *ConnectionManager) managementCredential(ctx context.Context, connection store.SecurityEventConnection) (string, []byte, error) {
if connection.ManagementCredentialRef != nil && connection.ManagementClientID != "" {
secret, err := m.secrets.Get(ctx, *connection.ManagementCredentialRef)
@ -882,8 +1206,10 @@ func (m *ConnectionManager) deleteStream(ctx context.Context, client *http.Clien
}
func (m *ConnectionManager) connectionError(ctx context.Context, connection store.SecurityEventConnection, category string, cause error) error {
_, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "error", &category)
return cause
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "error", &category,
)
return safeConnectionError{category: category, cause: cause}
}
func (m *ConnectionManager) view(connection store.SecurityEventConnection) ConnectionView {
@ -1054,7 +1380,12 @@ var blockedNetworkPrefixes = []netip.Prefix{
}
func isLocalHostname(value string) bool {
return value == "localhost" || value == "127.0.0.1" || value == "::1"
value = strings.ToLower(strings.TrimSpace(value))
if value == "localhost" {
return true
}
ip := net.ParseIP(value)
return ip != nil && ip.IsLoopback()
}
func isLocalEnvironment(value string) bool {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
package securityevents
import (
"context"
"errors"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
identitySecretCleanupInterval = time.Minute
identitySecretCleanupLease = 5 * time.Minute
identitySecretCleanupBatch = 100
)
type IdentitySecretCleanupRepository interface {
ClaimIdentitySecretCleanups(context.Context, int, time.Duration) ([]store.IdentitySecretCleanupClaim, error)
CompleteIdentitySecretCleanup(context.Context, string, string) (bool, error)
}
// RunIdentitySecretCleanupWorker owns cleanup at the server lifecycle rather
// than at any individual Active or Prepared identity Runtime. PostgreSQL claim
// tokens make running one worker per server replica safe.
func RunIdentitySecretCleanupWorker(ctx context.Context, repository IdentitySecretCleanupRepository, secrets SecretStore) {
if repository == nil || secrets == nil {
return
}
ticker := time.NewTicker(identitySecretCleanupInterval)
defer ticker.Stop()
for {
cleanupClaimedIdentitySecrets(ctx, repository, secrets)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func cleanupClaimedIdentitySecrets(ctx context.Context, repository IdentitySecretCleanupRepository, secrets SecretStore) {
claims, err := repository.ClaimIdentitySecretCleanups(ctx, identitySecretCleanupBatch, identitySecretCleanupLease)
if err != nil {
return
}
for _, claim := range claims {
if err := secrets.Delete(ctx, claim.Reference); err != nil && !errors.Is(err, ErrSecretNotFound) {
continue
}
_, _ = repository.CompleteIdentitySecretCleanup(ctx, claim.Reference, claim.ClaimToken)
}
}

View File

@ -0,0 +1,79 @@
package securityevents
import (
"context"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
defaultSecurityEventRetirementDuration = 6 * time.Minute
securityEventRetirementWorkerInterval = time.Minute
)
type SecurityEventRetirementRepository interface {
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
FinalizeRetiringSecurityEventConnection(context.Context, string, int64) error
}
// RunSecurityEventRetirementWorker owns the local end of SSF retirement at the
// server lifecycle. It intentionally does not share an Active identity
// Runtime's context, because that Runtime is closed before the RFC 8935 overlap
// period expires. Store-level generation CAS makes one worker per replica safe.
func RunSecurityEventRetirementWorker(ctx context.Context, repository SecurityEventRetirementRepository) {
runSecurityEventRetirementWorker(
ctx,
repository,
defaultSecurityEventRetirementDuration,
securityEventRetirementWorkerInterval,
)
}
func runSecurityEventRetirementWorker(
ctx context.Context,
repository SecurityEventRetirementRepository,
retirementDuration time.Duration,
interval time.Duration,
) {
if repository == nil {
return
}
if retirementDuration <= 0 {
retirementDuration = defaultSecurityEventRetirementDuration
}
if interval <= 0 {
interval = securityEventRetirementWorkerInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
finalizeDueSecurityEventRetirement(ctx, repository, retirementDuration)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func finalizeDueSecurityEventRetirement(
ctx context.Context,
repository SecurityEventRetirementRepository,
retirementDuration time.Duration,
) {
connection, err := repository.SecurityEventConnection(ctx)
if err != nil || connection.LifecycleStatus != "retiring" {
return
}
if retirementDuration <= 0 {
retirementDuration = defaultSecurityEventRetirementDuration
}
if time.Since(connection.UpdatedAt) < retirementDuration {
return
}
// Finalization atomically queues every Secret reference and removes only the
// exact retiring generation. A concurrent worker or changed generation is a
// benign CAS miss and will be observed on the next loop.
_ = repository.FinalizeRetiringSecurityEventConnection(ctx, connection.ConnectionID, connection.Version)
}

View File

@ -0,0 +1,178 @@
package securityevents
import (
"context"
"errors"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
)
func TestSecurityEventRetirementWorkerOutlivesCanceledRuntime(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC())
repository := &memoryConnectionRepository{connection: &connection}
secrets := &memorySecretStore{values: retirementWorkerTestSecrets(connection)}
runtimeCtx, cancelRuntime := context.WithCancel(context.Background())
manager := &ConnectionManager{
ctx: runtimeCtx, repository: repository, secrets: secrets,
config: ConnectionManagerConfig{RetirementDuration: 40 * time.Millisecond},
}
go manager.finishRetirement(connection)
cancelRuntime()
time.Sleep(10 * time.Millisecond)
if _, err := repository.SecurityEventConnection(context.Background()); err != nil {
t.Fatalf("runtime cancellation unexpectedly finalized retirement: %v", err)
}
serverCtx, cancelServer := context.WithCancel(context.Background())
defer cancelServer()
go runSecurityEventRetirementWorker(serverCtx, repository, 40*time.Millisecond, 5*time.Millisecond)
waitForRetirementFinalization(t, repository)
for reference := range retirementWorkerTestSecrets(connection) {
repository.mutex.Lock()
_, queued := repository.cleanupDue[reference]
repository.mutex.Unlock()
if !queued {
t.Fatalf("retired Secret %q was not durably queued", reference)
}
if !secrets.Has(reference) {
t.Fatalf("retirement worker directly deleted Secret %q before the durable cleanup worker claimed it", reference)
}
}
}
func TestSecurityEventRetirementWorkerResumesAfterServerRestart(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC())
repository := &memoryConnectionRepository{connection: &connection}
firstCtx, stopFirst := context.WithCancel(context.Background())
go runSecurityEventRetirementWorker(firstCtx, repository, 80*time.Millisecond, 5*time.Millisecond)
time.Sleep(15 * time.Millisecond)
stopFirst()
if _, err := repository.SecurityEventConnection(context.Background()); err != nil {
t.Fatalf("not-yet-due retirement disappeared before restart: %v", err)
}
time.Sleep(75 * time.Millisecond)
secondCtx, stopSecond := context.WithCancel(context.Background())
defer stopSecond()
go runSecurityEventRetirementWorker(secondCtx, repository, 80*time.Millisecond, 5*time.Millisecond)
waitForRetirementFinalization(t, repository)
}
func TestConcurrentSecurityEventRetirementWorkersFinalizeOneGenerationOnce(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
repository := &memoryConnectionRepository{connection: &connection}
start := make(chan struct{})
done := make(chan struct{}, 2)
for range 2 {
go func() {
<-start
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
done <- struct{}{}
}()
}
close(start)
<-done
<-done
if _, err := repository.SecurityEventConnection(context.Background()); !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
t.Fatalf("current retiring generation still exists: %v", err)
}
repository.mutex.Lock()
finalizeCalls := repository.finalizeCalls
queued := len(repository.cleanupDue)
repository.mutex.Unlock()
if finalizeCalls != 1 {
t.Fatalf("retirement generation finalized %d times, want 1", finalizeCalls)
}
if queued != len(retirementWorkerTestSecrets(connection)) {
t.Fatalf("queued Secret references=%d want=%d", queued, len(retirementWorkerTestSecrets(connection)))
}
}
func TestSecurityEventRetirementWorkerPreservesChangedGeneration(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
initialVersion := connection.Version
repository := &memoryConnectionRepository{connection: &connection}
repository.beforeFinalize = func(current *store.SecurityEventConnection) {
current.LifecycleStatus = "enabled"
current.Version++
}
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
current, err := repository.SecurityEventConnection(context.Background())
if err != nil || current.LifecycleStatus != "enabled" || current.Version != initialVersion+1 {
t.Fatalf("changed generation was not preserved: lifecycle=%q version=%d err=%v", current.LifecycleStatus, current.Version, err)
}
repository.mutex.Lock()
queued := len(repository.cleanupDue)
repository.mutex.Unlock()
if queued != 0 {
t.Fatalf("changed generation queued %d active Secret references", queued)
}
}
func TestSecurityEventRetirementWorkerKeepsConnectionWhenCleanupQueueCannotAdoptReferences(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
repository := &memoryConnectionRepository{
connection: &connection,
cleanupClaims: map[string]string{connection.CredentialRef: uuid.NewString()},
}
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
current, err := repository.SecurityEventConnection(context.Background())
if err != nil || current.ConnectionID != connection.ConnectionID || current.LifecycleStatus != "retiring" {
t.Fatalf("failed durable handoff lost retiring connection: lifecycle=%q err=%v", current.LifecycleStatus, err)
}
repository.mutex.Lock()
finalizeCalls := repository.finalizeCalls
repository.mutex.Unlock()
if finalizeCalls != 0 {
t.Fatalf("failed durable handoff finalized generation %d times", finalizeCalls)
}
}
func retiringWorkerTestConnection(updatedAt time.Time) store.SecurityEventConnection {
managementReference := "ssf-management-retirement-worker-" + uuid.NewString()
nextReference := "ssf-push-next-retirement-worker-" + uuid.NewString()
return store.SecurityEventConnection{
ConnectionID: uuid.NewString(),
CredentialRef: "ssf-push-retirement-worker-" + uuid.NewString(),
NextCredentialRef: &nextReference,
ManagementCredentialRef: &managementReference,
LifecycleStatus: "retiring",
Version: 9,
UpdatedAt: updatedAt,
}
}
func retirementWorkerTestSecrets(connection store.SecurityEventConnection) map[string][]byte {
secrets := map[string][]byte{connection.CredentialRef: []byte("retired-push-secret-value")}
if connection.NextCredentialRef != nil {
secrets[*connection.NextCredentialRef] = []byte("retired-next-push-secret-value")
}
if connection.ManagementCredentialRef != nil {
secrets[*connection.ManagementCredentialRef] = []byte("retired-management-secret-value")
}
return secrets
}
func waitForRetirementFinalization(t *testing.T, repository *memoryConnectionRepository) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if _, err := repository.SecurityEventConnection(context.Background()); errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("retiring connection was not finalized by the server-lifecycle worker")
}

View File

@ -0,0 +1,141 @@
package store
import (
"context"
"errors"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/google/uuid"
)
func TestDisableActiveIdentityRevisionQueuesSecretsAfterRuntimeRetirementAndClearsReferences(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
seedIdentityActivationPrerequisites(t, ctx, db, "default")
revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default")
machineReference := "identity-machine-disable-" + uuid.NewString()
sessionReference := "identity-session-disable-" + uuid.NewString()
if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_configuration_revisions
SET machine_credential_ref=$2,session_encryption_key_ref=$3 WHERE id=$1::uuid`,
revisionID, machineReference, sessionReference); err != nil {
t.Fatalf("seed active identity Secret references: %v", err)
}
if err := db.QueueIdentitySecretCleanup(ctx, machineReference, time.Now().Add(-time.Minute)); err != nil {
t.Fatalf("seed prematurely due machine Secret cleanup: %v", err)
}
disableStartedAt := time.Now().UTC()
disabled, err := db.DisableActiveIdentityRevision(ctx, 1, "trace-disable-secrets", "audit-disable-secrets")
if err != nil {
t.Fatalf("disable active identity revision: %v", err)
}
if disabled.State != identity.RevisionSuperseded || disabled.Version != 2 ||
disabled.MachineCredentialRef != "" || disabled.SessionEncryptionKeyRef != "" {
t.Fatalf("disabled revision retained Secret references: %#v", disabled)
}
persisted, err := db.IdentityConfigurationRevision(ctx, revisionID)
if err != nil {
t.Fatalf("read disabled identity revision: %v", err)
}
if persisted.State != identity.RevisionSuperseded || persisted.MachineCredentialRef != "" ||
persisted.SessionEncryptionKeyRef != "" {
t.Fatalf("persisted disabled revision retained Secret references: %#v", persisted)
}
type queuedSecret struct {
reference string
status string
notBefore time.Time
}
rows, err := db.pool.Query(ctx, `SELECT secret_ref,status,not_before
FROM gateway_identity_secret_cleanup_queue ORDER BY secret_ref`)
if err != nil {
t.Fatalf("read disabled identity Secret cleanup queue: %v", err)
}
defer rows.Close()
queued := make(map[string]queuedSecret, 2)
for rows.Next() {
var secret queuedSecret
if err := rows.Scan(&secret.reference, &secret.status, &secret.notBefore); err != nil {
t.Fatalf("scan disabled identity Secret cleanup: %v", err)
}
queued[secret.reference] = secret
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate disabled identity Secret cleanup queue: %v", err)
}
if len(queued) != 2 {
t.Fatalf("disabled identity cleanup queue entries=%v, want both Secret references", queued)
}
minimumSafeCleanup := disableStartedAt.Add(45 * time.Second)
for _, reference := range []string{machineReference, sessionReference} {
secret, ok := queued[reference]
if !ok || secret.status != "pending" || secret.notBefore.Before(minimumSafeCleanup) {
t.Fatalf("queued Secret %q=%#v present=%t, want pending cleanup no earlier than %s",
reference, secret, ok, minimumSafeCleanup)
}
}
claims, err := db.ClaimIdentitySecretCleanups(ctx, 10, time.Minute)
if err != nil {
t.Fatalf("claim not-yet-due disabled identity Secrets: %v", err)
}
if len(claims) != 0 {
t.Fatalf("disabled identity Secrets became claimable before Runtime retirement: %#v", claims)
}
}
func TestDisableActiveIdentityRevisionCleanupConflictRollsBackStateReferencesAndQueue(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
seedIdentityActivationPrerequisites(t, ctx, db, "default")
revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default")
machineReference := "identity-machine-disable-conflict-" + uuid.NewString()
sessionReference := "identity-session-disable-conflict-" + uuid.NewString()
if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_configuration_revisions
SET machine_credential_ref=$2,session_encryption_key_ref=$3 WHERE id=$1::uuid`,
revisionID, machineReference, sessionReference); err != nil {
t.Fatalf("seed conflicting active identity Secret references: %v", err)
}
if err := db.QueueIdentitySecretCleanup(ctx, machineReference, time.Now().Add(-time.Minute)); err != nil {
t.Fatalf("queue machine Secret before conflict claim: %v", err)
}
claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil || len(claims) != 1 || claims[0].Reference != machineReference {
t.Fatalf("claim machine Secret before disable: claims=%#v error=%v", claims, err)
}
_, err = db.DisableActiveIdentityRevision(ctx, 1, "trace-disable-conflict", "audit-disable-conflict")
if !errors.Is(err, ErrIdentitySecretCleanupConflict) {
t.Fatalf("disable with claimed Secret cleanup error=%v, want cleanup conflict", err)
}
persisted, err := db.IdentityConfigurationRevision(ctx, revisionID)
if err != nil {
t.Fatalf("read active identity revision after cleanup conflict: %v", err)
}
if persisted.State != identity.RevisionActive || persisted.Version != 1 ||
persisted.MachineCredentialRef != machineReference || persisted.SessionEncryptionKeyRef != sessionReference {
t.Fatalf("cleanup conflict partially disabled identity revision: %#v", persisted)
}
var status, claimToken string
if err := db.pool.QueryRow(ctx, `SELECT status,claim_token::text FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=$1`, machineReference).Scan(&status, &claimToken); err != nil {
t.Fatalf("read claimed machine Secret after disable rollback: %v", err)
}
if status != "claimed" || claimToken != claims[0].ClaimToken {
t.Fatalf("disable rollback changed claimed cleanup status=%q token=%q", status, claimToken)
}
var sessionQueueEntries int
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=$1`, sessionReference).Scan(&sessionQueueEntries); err != nil {
t.Fatalf("count session Secret cleanup after disable rollback: %v", err)
}
if sessionQueueEntries != 0 {
t.Fatalf("disable cleanup conflict left %d partial session cleanup entries", sessionQueueEntries)
}
}

View File

@ -29,6 +29,55 @@ COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),sess
session_refresh_seconds,version,COALESCE(last_error_category,''),COALESCE(last_trace_id,''),COALESCE(last_audit_id,''),
validated_at,activated_at,superseded_at,created_at,updated_at`
// identityConfigurationLifecycleLockID serializes the database-side boundary
// between onboarding reservations and Active Revision mutations. A transaction
// must hold this lock before deciding that no Active configuration or foreign
// pairing reservation exists.
const identityConfigurationLifecycleLockID int64 = 0x4541494944454e54
// identityDisabledSecretCleanupDelay is intentionally longer than the
// IdentityRuntimeManager's 30-second old-Runtime retirement window. Starting
// cleanup one minute after the database handoff leaves an additional 30
// seconds for commit, Runtime publication, timer scheduling, and worker jitter.
const identityDisabledSecretCleanupDelay = time.Minute
func lockIdentityConfigurationLifecycle(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, identityConfigurationLifecycleLockID)
return err
}
// beginIdentityConfigurationLifecycleTx deliberately uses READ COMMITTED.
// PostgreSQL fixes the transaction snapshot when a SERIALIZABLE transaction
// executes the advisory-lock SELECT, before that statement finishes waiting.
// A later state check can therefore miss the preceding lock holder's commit.
// READ COMMITTED gives every statement after lock acquisition a fresh snapshot,
// while the transaction-scoped advisory lock serializes all lifecycle decisions.
func (s *Store) beginIdentityConfigurationLifecycleTx(ctx context.Context) (pgx.Tx, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
if err != nil {
return nil, err
}
if err := lockIdentityConfigurationLifecycle(ctx, tx); err != nil {
_ = tx.Rollback(ctx)
return nil, err
}
return tx, nil
}
func queueDisabledIdentitySecretCleanupTx(ctx context.Context, tx pgx.Tx, reference string, notBefore time.Time) error {
tag, err := tx.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before)
VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE
SET not_before=GREATEST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now()
WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrIdentitySecretCleanupConflict
}
return nil
}
func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revision identity.Revision) (identity.Revision, error) {
scopes, _ := json.Marshal(revision.Scopes)
capabilities, _ := json.Marshal(revision.Capabilities)
@ -58,7 +107,16 @@ FROM gateway_identity_configuration_revisions WHERE state='active'`))
func (s *Store) LatestInactiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions WHERE state IN ('draft','validated','failed') ORDER BY created_at DESC LIMIT 1`))
FROM gateway_identity_configuration_revisions
WHERE id=(
SELECT revision.id
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')
ORDER BY NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') DESC,
revision.created_at DESC,exchange.created_at DESC
LIMIT 1
)`))
return revision, normalizeIdentityRevisionError(err)
}
@ -108,10 +166,16 @@ VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`,
}
func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) {
current, err := s.IdentityConfigurationRevision(ctx, id)
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return identity.Revision{}, err
}
defer tx.Rollback(ctx)
current, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, id))
if err != nil {
return identity.Revision{}, normalizeIdentityRevisionError(err)
}
if current.Version != expectedVersion {
return identity.Revision{}, identity.ErrRevisionConflict
}
@ -119,9 +183,20 @@ func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVe
if err != nil {
return identity.Revision{}, err
}
newReferences := make([]string, 0, 2)
for _, reference := range []string{updated.MachineCredentialRef, updated.SessionEncryptionKeyRef} {
if reference != "" {
newReferences = append(newReferences, reference)
}
}
if len(newReferences) > 0 {
if err := adoptPendingIdentitySecrets(ctx, tx, newReferences...); err != nil {
return identity.Revision{}, err
}
}
scopes, _ := json.Marshal(updated.Scopes)
capabilities, _ := json.Marshal(updated.Capabilities)
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `
UPDATE gateway_identity_configuration_revisions SET
issuer=$3,tenant_id=$4,application_id=$5,audience=NULLIF($6,''),browser_client_id=NULLIF($7,''),machine_client_id=NULLIF($8,''),
scopes=$9::jsonb,capabilities=$10::jsonb,token_introspection=$11,session_revocation=$12,
@ -138,14 +213,35 @@ RETURNING `+identityRevisionColumns,
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
}
return revision, err
if err != nil {
return identity.Revision{}, err
}
activeReferences := map[string]struct{}{}
for _, reference := range newReferences {
activeReferences[reference] = struct{}{}
}
for _, reference := range []string{current.MachineCredentialRef, current.SessionEncryptionKeyRef} {
if reference == "" {
continue
}
if _, stillActive := activeReferences[reference]; stillActive {
continue
}
if err := queueIdentitySecretCleanupTx(ctx, tx, reference, time.Now().UTC()); err != nil {
return identity.Revision{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return identity.Revision{}, err
}
return revision, nil
}
func (s *Store) MarkIdentityRevisionValidated(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
UPDATE gateway_identity_configuration_revisions SET state='validated',validated_at=now(),last_error_category=NULL,
last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND state IN ('draft','superseded')
WHERE id=$1::uuid AND version=$2 AND state='draft'
RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID))
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
@ -178,11 +274,22 @@ RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, aud
}
func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, bool, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
if err != nil {
return identity.Revision{}, false, err
}
defer tx.Rollback(ctx)
var foreignPairingReservation bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_pairing_start_reservation
WHERE (state='starting' AND expires_at > now())
OR (state='paired' AND revision_id IS DISTINCT FROM $1::uuid)
)`, id).Scan(&foreignPairingReservation); err != nil {
return identity.Revision{}, false, err
}
if foreignPairingReservation {
return identity.Revision{}, false, identity.ErrPairingInProgress
}
if ok, err := hasBreakGlassManager(ctx, tx); err != nil {
return identity.Revision{}, false, err
} else if !ok {
@ -197,9 +304,16 @@ SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$
return identity.Revision{}, false, identity.ErrLocalTenantInvalid
}
var previousID, previousIssuer, previousTenant, previousAudience, previousClient, previousSessionRef string
_ = tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''),
if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''),
COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions
WHERE state='active' FOR UPDATE`).Scan(&previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef)
WHERE state='active' FOR UPDATE`).Scan(
&previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef,
); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, false, err
}
if previousID != "" && previousID != id {
return identity.Revision{}, false, identity.ErrActiveConfigurationHandoffRequired
}
var nextIssuer, nextTenant, nextAudience, nextClient, nextSessionRef string
if err := tx.QueryRow(ctx, `SELECT COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''),
COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions
@ -228,6 +342,9 @@ WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevis
return identity.Revision{}, false, err
}
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, id); err != nil {
return identity.Revision{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return identity.Revision{}, false, err
}
@ -235,7 +352,7 @@ WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevis
}
func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
if err != nil {
return identity.Revision{}, err
}
@ -245,9 +362,33 @@ func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersi
} else if !ok {
return identity.Revision{}, identity.ErrBreakGlassRequired
}
var activeID, machineReference, sessionReference string
if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,'')
FROM gateway_identity_configuration_revisions
WHERE state='active' AND version=$1 FOR UPDATE`, expectedVersion).Scan(
&activeID, &machineReference, &sessionReference,
); errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
} else if err != nil {
return identity.Revision{}, err
}
cleanupNotBefore := time.Now().UTC().Add(identityDisabledSecretCleanupDelay)
references := make(map[string]struct{}, 2)
for _, reference := range []string{machineReference, sessionReference} {
if reference != "" {
references[reference] = struct{}{}
}
}
for reference := range references {
if err := queueDisabledIdentitySecretCleanupTx(ctx, tx, reference, cleanupNotBefore); err != nil {
return identity.Revision{}, err
}
}
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',
superseded_at=now(),last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''),version=version+1,updated_at=now()
WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion, traceID, auditID))
superseded_at=now(),machine_credential_ref=NULL,session_encryption_key_ref=NULL,
last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
WHERE id=$1::uuid AND state='active' AND version=$2
RETURNING `+identityRevisionColumns, activeID, expectedVersion, traceID, auditID))
if errors.Is(err, pgx.ErrNoRows) {
return identity.Revision{}, identity.ErrRevisionConflict
}

View File

@ -2,24 +2,156 @@ package store
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/jackc/pgx/v5"
)
const identityPairingColumns = `
id::text,revision_id::text,remote_exchange_id::text,exchange_token_ref,status,remote_version,expires_at,version,
COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),created_at,updated_at`
id::text,revision_id::text,remote_exchange_id::text,exchange_token_ref,status,cleanup_status,remote_version,expires_at,version,
COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),cancelled_at,cleanup_completed_at,
created_at,updated_at`
func (s *Store) ReserveIdentityPairingStart(ctx context.Context, attemptID string, expiresAt time.Time) error {
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation
WHERE state='starting' AND expires_at <= now()`); err != nil {
return err
}
var activeConfigurationExists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_configuration_revisions
WHERE state='active'
)`).Scan(&activeConfigurationExists); err != nil {
return err
}
if activeConfigurationExists {
return identity.ErrActiveConfigurationHandoffRequired
}
var blocked bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 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')
)`).Scan(&blocked); err != nil {
return err
}
if blocked {
return identity.ErrPairingInProgress
}
if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,expires_at)
VALUES(true,$1::uuid,'starting',$2)`, attemptID, expiresAt); err != nil {
if isUniqueViolation(err) {
return identity.ErrPairingInProgress
}
return err
}
return tx.Commit(ctx)
}
func (s *Store) ReleaseIdentityPairingStart(ctx context.Context, attemptID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation
WHERE attempt_id=$1::uuid AND state='starting'`, attemptID)
return err
}
func (s *Store) CommitIdentityPairingStart(ctx context.Context, revision identity.Revision, exchange identity.PairingExchange) (identity.PairingExchange, error) {
tx, err := s.beginIdentityConfigurationLifecycleTx(ctx)
if err != nil {
return identity.PairingExchange{}, err
}
defer tx.Rollback(ctx)
var reserved bool
if err := tx.QueryRow(ctx, `SELECT true FROM gateway_identity_pairing_start_reservation
WHERE singleton=true AND attempt_id=$1::uuid AND state='starting' FOR UPDATE`, exchange.ID).Scan(&reserved); errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrPairingInProgress
} else if err != nil {
return identity.PairingExchange{}, err
}
if !reserved {
return identity.PairingExchange{}, identity.ErrPairingInProgress
}
var activeConfigurationExists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_configuration_revisions WHERE state='active'
)`).Scan(&activeConfigurationExists); err != nil {
return identity.PairingExchange{}, err
}
if activeConfigurationExists {
return identity.PairingExchange{}, identity.ErrActiveConfigurationHandoffRequired
}
if err := adoptPendingIdentitySecrets(ctx, tx, exchange.ExchangeTokenRef); err != nil {
return identity.PairingExchange{}, err
}
scopes, _ := json.Marshal(revision.Scopes)
capabilities, _ := json.Marshal(revision.Capabilities)
if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url,
jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds,last_trace_id
) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15,NULLIF($16,''))`,
revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix,
revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled,
string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds,
revision.SessionRefreshSeconds, revision.LastTraceID,
); err != nil {
return identity.PairingExchange{}, err
}
created, err := scanIdentityPairing(tx.QueryRow(ctx, `INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,''))
RETURNING `+identityPairingColumns,
exchange.ID, revision.ID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef,
exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID,
))
if err != nil {
return identity.PairingExchange{}, err
}
tag, err := tx.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation
SET state='paired',revision_id=$2::uuid,expires_at=$3,updated_at=now()
WHERE attempt_id=$1::uuid AND state='starting'`, exchange.ID, revision.ID, exchange.ExpiresAt)
if err != nil {
return identity.PairingExchange{}, err
}
if tag.RowsAffected() != 1 {
return identity.PairingExchange{}, identity.ErrPairingInProgress
}
if err := tx.Commit(ctx); err != nil {
return identity.PairingExchange{}, err
}
return created, nil
}
func (s *Store) IdentityPairingStartBlocked(ctx context.Context) (bool, error) {
var blocked bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 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')
) OR EXISTS (
SELECT 1 FROM gateway_identity_pairing_start_reservation
WHERE state='paired' OR expires_at > now()
)`).Scan(&blocked)
return blocked, err
}
func (s *Store) CreateIdentityPairingExchange(ctx context.Context, exchange identity.PairingExchange) (identity.PairingExchange, error) {
return scanIdentityPairing(s.pool.QueryRow(ctx, `
INSERT INTO gateway_identity_onboarding_exchanges (
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at,last_trace_id
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,NULLIF($8,''))
id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,''))
RETURNING `+identityPairingColumns,
exchange.ID, exchange.RevisionID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef,
exchange.Status, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID,
exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID,
))
}
@ -43,8 +175,67 @@ FROM gateway_identity_onboarding_exchanges WHERE revision_id=$1::uuid`, revision
func (s *Store) PendingIdentityPairingExchanges(ctx context.Context) ([]identity.PairingExchange, error) {
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges exchange
WHERE exchange.id=(
SELECT candidate_exchange.id
FROM gateway_identity_onboarding_exchanges candidate_exchange
JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id
WHERE revision.state IN ('draft','validated','failed')
ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC,
revision.created_at DESC,candidate_exchange.created_at DESC
LIMIT 1
)
AND exchange.status IN ('metadata_pending','preparing','ready','credentials_saved')`)
if err != nil {
return nil, err
}
defer rows.Close()
exchanges := make([]identity.PairingExchange, 0)
for rows.Next() {
exchange, scanErr := scanIdentityPairing(rows)
if scanErr != nil {
return nil, scanErr
}
exchanges = append(exchanges, exchange)
}
return exchanges, rows.Err()
}
func (s *Store) PendingIdentityPairingCleanups(ctx context.Context) ([]identity.PairingExchange, error) {
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges
WHERE status IN ('metadata_pending','preparing','ready','credentials_saved') ORDER BY created_at`)
WHERE status='cancelled' AND cleanup_status='pending' ORDER BY updated_at`)
if err != nil {
return nil, err
}
defer rows.Close()
exchanges := make([]identity.PairingExchange, 0)
for rows.Next() {
exchange, scanErr := scanIdentityPairing(rows)
if scanErr != nil {
return nil, scanErr
}
exchanges = append(exchanges, exchange)
}
return exchanges, rows.Err()
}
func (s *Store) CompletedIdentityPairingsAwaitingActivation(ctx context.Context) ([]identity.PairingExchange, error) {
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges exchange
WHERE exchange.id=(
SELECT candidate_exchange.id
FROM gateway_identity_onboarding_exchanges candidate_exchange
JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id
WHERE revision.state IN ('draft','validated','failed')
ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC,
revision.created_at DESC,candidate_exchange.created_at DESC
LIMIT 1
)
AND exchange.status='completed' AND EXISTS (
SELECT 1 FROM gateway_identity_configuration_revisions revision
WHERE revision.id=exchange.revision_id AND revision.state IN ('draft','validated')
)`)
if err != nil {
return nil, err
}
@ -61,13 +252,139 @@ WHERE status IN ('metadata_pending','preparing','ready','credentials_saved') ORD
}
func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) {
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `
tx, err := s.pool.Begin(ctx)
if err != nil {
return identity.PairingExchange{}, err
}
defer tx.Rollback(ctx)
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `
UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''),
auth_center_audit_id=COALESCE(NULLIF($6,''),auth_center_audit_id),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2
RETURNING `+identityPairingColumns,
id, expectedVersion, update.Status, update.RemoteVersion, update.LastErrorCategory, update.AuthCenterAuditID,
))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
if err != nil {
return identity.PairingExchange{}, err
}
if update.Status == identity.PairingCompleted || update.Status == identity.PairingFailed || update.Status == identity.PairingExpired {
if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil {
return identity.PairingExchange{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return identity.PairingExchange{}, err
}
return exchange, nil
}
func (s *Store) RecordIdentityPairingRetryFailure(ctx context.Context, id string, status identity.PairingStatus, category string) (identity.PairingExchange, error) {
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `
UPDATE gateway_identity_onboarding_exchanges SET last_error_category=$3,updated_at=now()
WHERE id=$1::uuid AND status=$2 AND cleanup_status='none'
RETURNING `+identityPairingColumns, id, status, category))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
return exchange, err
}
func (s *Store) CancelIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.PairingExchange, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return identity.PairingExchange{}, err
}
defer tx.Rollback(ctx)
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `SELECT `+identityPairingColumns+`
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid FOR UPDATE`, id))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionNotFound
}
if err != nil {
return identity.PairingExchange{}, err
}
if exchange.Status == identity.PairingCancelled {
return exchange, tx.Commit(ctx)
}
if exchange.Version != expectedVersion {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, exchange.RevisionID))
if err != nil {
return identity.PairingExchange{}, normalizeIdentityRevisionError(err)
}
if revision.State != identity.RevisionDraft && revision.State != identity.RevisionValidated && revision.State != identity.RevisionFailed {
return identity.PairingExchange{}, identity.ErrPairingNotCancellable
}
if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='failed',
last_error_category='pairing_cancelled',last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''),
version=version+1,updated_at=now() WHERE id=$1::uuid`, exchange.RevisionID, traceID, auditID); err != nil {
return identity.PairingExchange{}, err
}
exchange, err = scanIdentityPairing(tx.QueryRow(ctx, `
UPDATE gateway_identity_onboarding_exchanges SET status='cancelled',cleanup_status='pending',cancelled_at=now(),
cleanup_completed_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2
RETURNING `+identityPairingColumns, id, expectedVersion))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
if err != nil {
return identity.PairingExchange{}, err
}
if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil {
return identity.PairingExchange{}, err
}
if err := tx.Commit(ctx); err != nil {
return identity.PairingExchange{}, err
}
return exchange, nil
}
func (s *Store) CompleteIdentityPairingCleanup(ctx context.Context, id string, expectedVersion int64) (identity.PairingExchange, error) {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return identity.PairingExchange{}, err
}
defer tx.Rollback(ctx)
var revisionID string
if err := tx.QueryRow(ctx, `SELECT revision_id::text FROM gateway_identity_onboarding_exchanges
WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending' FOR UPDATE`, id, expectedVersion).Scan(&revisionID); errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
} else if err != nil {
return identity.PairingExchange{}, err
}
tag, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET machine_credential_ref=NULL,
session_encryption_key_ref=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND state='failed'`, revisionID)
if err != nil {
return identity.PairingExchange{}, err
}
if tag.RowsAffected() != 1 {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET cleanup_status='completed',cleanup_completed_at=now(),version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending'
RETURNING `+identityPairingColumns, id, expectedVersion))
if err != nil {
return identity.PairingExchange{}, err
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, revisionID); err != nil {
return identity.PairingExchange{}, err
}
if err := tx.Commit(ctx); err != nil {
return identity.PairingExchange{}, err
}
return exchange, nil
}
func (s *Store) RecordIdentityPairingCleanupFailure(ctx context.Context, id, category string) (identity.PairingExchange, error) {
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges
SET last_error_category=$2,updated_at=now() WHERE id=$1::uuid AND status='cancelled' AND cleanup_status='pending'
RETURNING `+identityPairingColumns, id, category))
if errors.Is(err, pgx.ErrNoRows) {
return identity.PairingExchange{}, identity.ErrRevisionConflict
}
@ -78,9 +395,10 @@ func scanIdentityPairing(row scanner) (identity.PairingExchange, error) {
var exchange identity.PairingExchange
var status string
if err := row.Scan(
&exchange.ID, &exchange.RevisionID, &exchange.RemoteExchangeID, &exchange.ExchangeTokenRef, &status,
&exchange.ID, &exchange.RevisionID, &exchange.RemoteExchangeID, &exchange.ExchangeTokenRef, &status, &exchange.CleanupStatus,
&exchange.RemoteVersion, &exchange.ExpiresAt, &exchange.Version, &exchange.LastErrorCategory,
&exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CreatedAt, &exchange.UpdatedAt,
&exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CancelledAt, &exchange.CleanupCompletedAt,
&exchange.CreatedAt, &exchange.UpdatedAt,
); err != nil {
return identity.PairingExchange{}, err
}

View File

@ -0,0 +1,715 @@
package store
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestIdentityPairingCancellationKeepsOlderInactivePairingBlockedUntilEveryCleanupCompletes(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
type pairingFixture struct {
revisionID string
pairingID string
machineRef string
sessionRef string
createdAt time.Time
displayName string
}
now := time.Now().UTC()
fixtures := []pairingFixture{
{
revisionID: uuid.NewString(), pairingID: uuid.NewString(),
machineRef: "identity-machine-older", sessionRef: "identity-session-older",
createdAt: now.Add(-time.Minute), displayName: "older",
},
{
revisionID: uuid.NewString(), pairingID: uuid.NewString(),
machineRef: "identity-machine-latest", sessionRef: "identity-session-latest",
createdAt: now, displayName: "latest",
},
}
for _, fixture := range fixtures {
if _, err := db.pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url,
machine_credential_ref,session_encryption_key_ref,created_at,updated_at
) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://gateway.test.example',
'https://gateway-web.test.example',$2,$3,$4,$4)`,
fixture.revisionID, fixture.machineRef, fixture.sessionRef, fixture.createdAt); err != nil {
t.Fatalf("seed %s inactive revision: %v", fixture.displayName, err)
}
if _, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{
ID: fixture.pairingID, RevisionID: fixture.revisionID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: "identity-exchange-" + fixture.pairingID,
Status: identity.PairingCredentialsSaved, CleanupStatus: identity.PairingCleanupNone,
RemoteVersion: 4, ExpiresAt: now.Add(time.Hour), LastTraceID: "trace-start-" + fixture.displayName,
}); err != nil {
t.Fatalf("seed %s pairing: %v", fixture.displayName, err)
}
}
orphanRevisionID := uuid.NewString()
if _, err := db.pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url,created_at,updated_at
) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://orphan-api.test.example',
'https://orphan-web.test.example',$2,$2)`, orphanRevisionID, now.Add(time.Minute)); err != nil {
t.Fatalf("seed orphan revision: %v", err)
}
canonical, err := db.LatestInactiveIdentityConfigurationRevision(ctx)
if err != nil || canonical.ID != fixtures[1].revisionID {
t.Fatalf("canonical inactive revision=%#v error=%v, want latest exchange-backed revision", canonical, err)
}
blocked, err := db.IdentityPairingStartBlocked(ctx)
if err != nil || !blocked {
t.Fatalf("two inactive pairings blocked=%t error=%v, want blocked", blocked, err)
}
latest := fixtures[1]
latestCancelled, err := db.CancelIdentityPairingExchange(ctx, latest.pairingID, 1, "trace-cancel-latest", "audit-cancel-latest")
if err != nil {
t.Fatalf("cancel latest pairing: %v", err)
}
latestReplay, err := db.CancelIdentityPairingExchange(ctx, latest.pairingID, 1, "trace-replayed", "audit-replayed")
if err != nil {
t.Fatalf("replay latest cancellation with original version: %v", err)
}
if latestReplay.Status != identity.PairingCancelled || latestReplay.CleanupStatus != identity.PairingCleanupPending ||
latestReplay.Version != latestCancelled.Version || latestCancelled.CancelledAt == nil || latestReplay.CancelledAt == nil ||
!latestReplay.CancelledAt.Equal(*latestCancelled.CancelledAt) {
t.Fatalf("cancellation replay changed persisted intent: first=%#v replay=%#v", latestCancelled, latestReplay)
}
latestCleaned, err := db.CompleteIdentityPairingCleanup(ctx, latest.pairingID, latestCancelled.Version)
if err != nil {
t.Fatalf("complete latest cleanup: %v", err)
}
if latestCleaned.CleanupStatus != identity.PairingCleanupCompleted || latestCleaned.CleanupCompletedAt == nil {
t.Fatalf("latest cleanup state=%#v", latestCleaned)
}
assertIdentityRevisionSecretRefs(t, ctx, db, latest.revisionID, "", "")
assertIdentityRevisionSecretRefs(t, ctx, db, fixtures[0].revisionID, fixtures[0].machineRef, fixtures[0].sessionRef)
blocked, err = db.IdentityPairingStartBlocked(ctx)
if err != nil || !blocked {
t.Fatalf("older pairing was ignored after latest cleanup: blocked=%t error=%v", blocked, err)
}
canonical, err = db.LatestInactiveIdentityConfigurationRevision(ctx)
if err != nil || canonical.ID != fixtures[0].revisionID {
t.Fatalf("canonical inactive revision after latest cleanup=%#v error=%v, want older blocker", canonical, err)
}
older := fixtures[0]
olderCancelled, err := db.CancelIdentityPairingExchange(ctx, older.pairingID, 1, "trace-cancel-older", "audit-cancel-older")
if err != nil {
t.Fatalf("cancel older pairing: %v", err)
}
olderReplay, err := db.CancelIdentityPairingExchange(ctx, older.pairingID, 1, "trace-replayed", "audit-replayed")
if err != nil || olderReplay.Version != olderCancelled.Version {
t.Fatalf("replay older cancellation: replay=%#v error=%v", olderReplay, err)
}
if _, err := db.CompleteIdentityPairingCleanup(ctx, older.pairingID, olderCancelled.Version); err != nil {
t.Fatalf("complete older cleanup: %v", err)
}
assertIdentityRevisionSecretRefs(t, ctx, db, older.revisionID, "", "")
blocked, err = db.IdentityPairingStartBlocked(ctx)
if err != nil || blocked {
t.Fatalf("all inactive pairing cleanup blocked=%t error=%v, want unblocked", blocked, err)
}
canonical, err = db.LatestInactiveIdentityConfigurationRevision(ctx)
if err != nil || canonical.ID != fixtures[1].revisionID || canonical.ID == orphanRevisionID {
t.Fatalf("canonical cleaned revision=%#v error=%v, orphan draft must stay hidden", canonical, err)
}
}
func TestIdentityPairingStartReservationSerializesAndRejectsStaleCommit(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
firstAttempt := uuid.NewString()
secondAttempt := uuid.NewString()
if err := db.ReserveIdentityPairingStart(ctx, firstAttempt, time.Now().Add(time.Minute)); err != nil {
t.Fatalf("reserve first pairing start: %v", err)
}
if err := db.ReserveIdentityPairingStart(ctx, secondAttempt, time.Now().Add(time.Minute)); !errors.Is(err, identity.ErrPairingInProgress) {
t.Fatalf("second pairing reservation error=%v, want in-progress conflict", err)
}
if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET expires_at=now()-interval '1 second'
WHERE attempt_id=$1::uuid`, firstAttempt); err != nil {
t.Fatalf("expire first pairing reservation: %v", err)
}
if err := db.ReserveIdentityPairingStart(ctx, secondAttempt, time.Now().Add(time.Minute)); err != nil {
t.Fatalf("take over expired pairing reservation: %v", err)
}
staleRevision := identity.Revision{
ID: uuid.NewString(), State: identity.RevisionDraft, SchemaVersion: 1, AuthCenterURL: "https://auth.test.example",
RolePrefix: "gateway.", LocalTenantKey: "default", PublicBaseURL: "https://gateway.test.example",
WebBaseURL: "https://gateway-web.test.example", Scopes: []string{}, Capabilities: []string{},
SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, SessionRefreshSeconds: 60,
}
if _, err := db.CommitIdentityPairingStart(ctx, staleRevision, identity.PairingExchange{
ID: firstAttempt, RevisionID: staleRevision.ID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: "identity-exchange-" + firstAttempt, Status: identity.PairingMetadataPending,
CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 1, ExpiresAt: time.Now().Add(time.Minute),
}); !errors.Is(err, identity.ErrPairingInProgress) {
t.Fatalf("stale start commit error=%v, want in-progress conflict", err)
}
validRevision := staleRevision
validRevision.ID = uuid.NewString()
tokenReference := "identity-exchange-" + secondAttempt
if err := db.QueueIdentitySecretCleanup(ctx, tokenReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatalf("stage replacement exchange token reference: %v", err)
}
committed, err := db.CommitIdentityPairingStart(ctx, validRevision, identity.PairingExchange{
ID: secondAttempt, RevisionID: validRevision.ID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: tokenReference, Status: identity.PairingMetadataPending,
CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 1, ExpiresAt: time.Now().Add(time.Minute),
})
if err != nil {
t.Fatalf("commit replacement pairing: %v", err)
}
if err := db.ReleaseIdentityPairingStart(ctx, secondAttempt); err != nil {
t.Fatalf("release paired reservation: %v", err)
}
var reservationState string
if err := db.pool.QueryRow(ctx, `SELECT state FROM gateway_identity_pairing_start_reservation
WHERE attempt_id=$1::uuid`, secondAttempt).Scan(&reservationState); err != nil || reservationState != "paired" {
t.Fatalf("paired reservation state=%q error=%v", reservationState, err)
}
blocked, err := db.IdentityPairingStartBlocked(ctx)
if err != nil || !blocked {
t.Fatalf("committed pairing did not retain singleton reservation: blocked=%t error=%v", blocked, err)
}
cancelled, err := db.CancelIdentityPairingExchange(ctx, committed.ID, committed.Version, "trace-cancel", "audit-cancel")
if err != nil {
t.Fatalf("cancel replacement pairing: %v", err)
}
if _, err := db.CompleteIdentityPairingCleanup(ctx, committed.ID, cancelled.Version); err != nil {
t.Fatalf("complete replacement pairing cleanup: %v", err)
}
blocked, err = db.IdentityPairingStartBlocked(ctx)
if err != nil || blocked {
t.Fatalf("completed cleanup retained singleton reservation: blocked=%t error=%v", blocked, err)
}
}
func TestIdentityPairingStartRejectsAnyActiveConfigurationBeforeReservation(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'active','https://auth.test.example','default','https://gateway.test.example',
'https://gateway-web.test.example')`, uuid.NewString()); err != nil {
t.Fatalf("seed active identity revision: %v", err)
}
err := db.ReserveIdentityPairingStart(ctx, uuid.NewString(), time.Now().Add(time.Minute))
if !errors.Is(err, identity.ErrActiveConfigurationHandoffRequired) {
t.Fatalf("active identity configuration reservation error=%v", err)
}
var reservations int
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_identity_pairing_start_reservation`).Scan(&reservations); err != nil {
t.Fatal(err)
}
if reservations != 0 {
t.Fatalf("active credential guard left %d start reservations", reservations)
}
}
func TestConcurrentIdentityPairingStartReservationsHaveSingleWinner(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
attempts := []string{uuid.NewString(), uuid.NewString()}
ready := make(chan struct{}, len(attempts))
start := make(chan struct{})
type reservationResult struct {
attemptID string
err error
}
results := make(chan reservationResult, len(attempts))
for _, attemptID := range attempts {
go func() {
ready <- struct{}{}
<-start
results <- reservationResult{
attemptID: attemptID,
err: db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute)),
}
}()
}
for range attempts {
<-ready
}
close(start)
var winner string
conflicts := 0
for range attempts {
result := <-results
switch {
case result.err == nil:
if winner != "" {
t.Fatalf("multiple concurrent reservations succeeded: %s and %s", winner, result.attemptID)
}
winner = result.attemptID
case errors.Is(result.err, identity.ErrPairingInProgress):
conflicts++
default:
t.Fatalf("reserve concurrent pairing start %s: %v", result.attemptID, result.err)
}
}
if winner == "" || conflicts != 1 {
t.Fatalf("concurrent reservation winner=%q conflicts=%d, want one winner and one conflict", winner, conflicts)
}
var persistedAttemptID, state string
var reservations int
if err := db.pool.QueryRow(ctx, `SELECT count(*),COALESCE(max(attempt_id::text),''),COALESCE(max(state),'')
FROM gateway_identity_pairing_start_reservation`).Scan(&reservations, &persistedAttemptID, &state); err != nil {
t.Fatalf("read concurrent reservation result: %v", err)
}
if reservations != 1 || persistedAttemptID != winner || state != "starting" {
t.Fatalf("persisted reservations=%d attempt=%q state=%q, want winner %q in starting state",
reservations, persistedAttemptID, state, winner)
}
}
func TestConcurrentIdentityActivationAndPairingReservationRemainMutuallyExclusive(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
seedIdentityActivationPrerequisites(t, ctx, db, "default")
revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionValidated, "default")
attemptID := uuid.NewString()
gate, err := db.pool.Begin(ctx)
if err != nil {
t.Fatalf("begin identity lifecycle gate: %v", err)
}
defer gate.Rollback(context.Background())
if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil {
t.Fatalf("lock identity lifecycle gate: %v", err)
}
reserveResult := make(chan error, 1)
go func() {
reserveResult <- db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute))
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 1)
type activationResult struct {
revision identity.Revision
err error
}
activateResult := make(chan activationResult, 1)
go func() {
revision, _, activateErr := db.ActivateIdentityRevision(ctx, revisionID, 1, "trace-activate", "audit-activate")
activateResult <- activationResult{revision: revision, err: activateErr}
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 2)
if err := gate.Commit(ctx); err != nil {
t.Fatalf("release identity lifecycle gate: %v", err)
}
reserveErr := <-reserveResult
activated := <-activateResult
if reserveErr != nil {
t.Fatalf("first queued pairing reservation failed: %v", reserveErr)
}
var activeRevisions, startingReservations int
if err := db.pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM gateway_identity_configuration_revisions WHERE state='active'),
(SELECT count(*) FROM gateway_identity_pairing_start_reservation WHERE state='starting')`).Scan(
&activeRevisions, &startingReservations,
); err != nil {
t.Fatalf("read activation and reservation invariant: %v", err)
}
if !errors.Is(activated.err, identity.ErrPairingInProgress) || activeRevisions != 0 || startingReservations != 1 {
t.Fatalf("racing activation error=%v revision_state=%q left active=%d starting reservations=%d; "+
"want pairing-in-progress, active=0, reservation=1",
activated.err, activated.revision.State, activeRevisions, startingReservations)
}
}
func TestConcurrentIdentityPairingCommitRejectsActiveRevisionCommittedAheadOfLifecycleLock(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
attemptID := uuid.NewString()
if err := db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute)); err != nil {
t.Fatalf("reserve pairing start before commit race: %v", err)
}
tokenReference := "identity-exchange-" + attemptID
if err := db.QueueIdentitySecretCleanup(ctx, tokenReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatalf("stage exchange token reference before commit race: %v", err)
}
draft := identity.Revision{
ID: uuid.NewString(), State: identity.RevisionDraft, SchemaVersion: 1, AuthCenterURL: "https://auth.test.example",
RolePrefix: "gateway.", LocalTenantKey: "default", PublicBaseURL: "https://gateway.test.example",
WebBaseURL: "https://gateway-web.test.example", Scopes: []string{}, Capabilities: []string{},
SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, SessionRefreshSeconds: 60,
}
exchange := identity.PairingExchange{
ID: attemptID, RevisionID: draft.ID, RemoteExchangeID: uuid.NewString(), ExchangeTokenRef: tokenReference,
Status: identity.PairingMetadataPending, CleanupStatus: identity.PairingCleanupNone,
RemoteVersion: 1, ExpiresAt: time.Now().Add(5 * time.Minute),
}
gate, err := db.pool.Begin(ctx)
if err != nil {
t.Fatalf("begin identity lifecycle gate: %v", err)
}
defer gate.Rollback(context.Background())
if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil {
t.Fatalf("lock identity lifecycle gate: %v", err)
}
commitResult := make(chan error, 1)
go func() {
_, commitErr := db.CommitIdentityPairingStart(ctx, draft, exchange)
commitResult <- commitErr
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 1)
activeID := uuid.NewString()
if _, err := gate.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'active','https://auth.test.example','default','https://active-gateway.test.example',
'https://active-gateway-web.test.example')`, activeID); err != nil {
t.Fatalf("commit active revision ahead of pairing commit: %v", err)
}
if err := gate.Commit(ctx); err != nil {
t.Fatalf("release identity lifecycle gate with active revision: %v", err)
}
commitErr := <-commitResult
if !errors.Is(commitErr, identity.ErrActiveConfigurationHandoffRequired) {
t.Fatalf("pairing commit after active revision error=%v, want active handoff required", commitErr)
}
var activeRevisions, draftRevisions, exchanges, startingReservations int
if err := db.pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM gateway_identity_configuration_revisions WHERE state='active'),
(SELECT count(*) FROM gateway_identity_configuration_revisions WHERE id=$1::uuid),
(SELECT count(*) FROM gateway_identity_onboarding_exchanges WHERE id=$2::uuid),
(SELECT count(*) FROM gateway_identity_pairing_start_reservation WHERE attempt_id=$2::uuid AND state='starting')`,
draft.ID, attemptID).Scan(&activeRevisions, &draftRevisions, &exchanges, &startingReservations); err != nil {
t.Fatalf("read pairing commit lifecycle invariant: %v", err)
}
if activeRevisions != 1 || draftRevisions != 0 || exchanges != 0 || startingReservations != 1 {
t.Fatalf("pairing commit race left active=%d draft=%d exchanges=%d starting reservations=%d; "+
"want active=1 draft=0 exchanges=0 reservation=1",
activeRevisions, draftRevisions, exchanges, startingReservations)
}
}
func TestConcurrentIdentityDisableAndActivationPreserveSingleActiveRevision(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
seedIdentityActivationPrerequisites(t, ctx, db, "default")
previousID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default")
nextID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionValidated, "default")
gate, err := db.pool.Begin(ctx)
if err != nil {
t.Fatalf("begin identity lifecycle gate: %v", err)
}
defer gate.Rollback(context.Background())
if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil {
t.Fatalf("lock identity lifecycle gate: %v", err)
}
disableResult := make(chan error, 1)
go func() {
_, disableErr := db.DisableActiveIdentityRevision(ctx, 1, "trace-disable", "audit-disable")
disableResult <- disableErr
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 1)
activateResult := make(chan error, 1)
go func() {
_, _, activateErr := db.ActivateIdentityRevision(ctx, nextID, 1, "trace-activate", "audit-activate")
activateResult <- activateErr
}()
waitForIdentityLifecycleLockWaiters(t, ctx, db, 2)
if err := gate.Commit(ctx); err != nil {
t.Fatalf("release identity lifecycle gate: %v", err)
}
if err := <-disableResult; err != nil {
t.Fatalf("disable first queued active revision: %v", err)
}
activateErr := <-activateResult
if activateErr != nil {
t.Fatalf("activate revision after first queued disable: %v", activateErr)
}
var activeIDs []string
rows, err := db.pool.Query(ctx, `SELECT id::text FROM gateway_identity_configuration_revisions
WHERE state='active' ORDER BY id`)
if err != nil {
t.Fatalf("read active revisions after disable/activate race: %v", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("scan active revision after disable/activate race: %v", err)
}
activeIDs = append(activeIDs, id)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate active revisions after disable/activate race: %v", err)
}
if len(activeIDs) != 1 || activeIDs[0] != nextID {
t.Fatalf("disable/activate race left active revisions=%v; previous=%q next=%q",
activeIDs, previousID, nextID)
}
}
func TestCompletedIdentityPairingRestoreUsesCanonicalVisibleRevision(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
var latestRevisionID string
for index, createdAt := range []time.Time{now.Add(-time.Minute), now} {
revisionID, pairingID := uuid.NewString(), uuid.NewString()
if _, err := db.pool.Exec(ctx, `
INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url,created_at,updated_at
) VALUES ($1::uuid,'validated','https://auth.test.example','default','https://gateway.test.example',
'https://gateway-web.test.example',$2,$2)`, revisionID, createdAt); err != nil {
t.Fatalf("seed completed revision %d: %v", index, err)
}
if _, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{
ID: pairingID, RevisionID: revisionID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: "identity-exchange-" + pairingID, Status: identity.PairingCompleted,
CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 4, ExpiresAt: now.Add(time.Hour),
}); err != nil {
t.Fatalf("seed completed pairing %d: %v", index, err)
}
latestRevisionID = revisionID
}
completed, err := db.CompletedIdentityPairingsAwaitingActivation(ctx)
if err != nil {
t.Fatal(err)
}
if len(completed) != 1 || completed[0].RevisionID != latestRevisionID {
t.Fatalf("restored completed pairings=%#v, want only canonical latest revision %s", completed, latestRevisionID)
}
visible, err := db.LatestInactiveIdentityConfigurationRevision(ctx)
if err != nil || visible.ID != latestRevisionID {
t.Fatalf("visible inactive revision=%#v error=%v, want same canonical revision", visible, err)
}
}
func TestIdentityPairingPersistsBindingErrorCategoryAllowedByLatestMigration(t *testing.T) {
db := newIdentityPairingPostgresTestStore(t)
ctx := context.Background()
revisionID := uuid.NewString()
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://gateway.test.example',
'https://gateway-web.test.example')`, revisionID); err != nil {
t.Fatalf("seed revision for binding error category: %v", err)
}
pairingID := uuid.NewString()
created, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{
ID: pairingID, RevisionID: revisionID, RemoteExchangeID: uuid.NewString(),
ExchangeTokenRef: "identity-exchange-" + pairingID, Status: identity.PairingCredentialsSaved,
CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatalf("create pairing for binding error category: %v", err)
}
const category = "security_event_connection_binding_mismatch"
updated, err := db.RecordIdentityPairingRetryFailure(ctx, created.ID, created.Status, category)
if err != nil {
t.Fatalf("persist binding error category: %v", err)
}
if updated.LastErrorCategory != category {
t.Fatalf("binding error category=%q, want %q", updated.LastErrorCategory, category)
}
var persisted string
if err := db.pool.QueryRow(ctx, `SELECT last_error_category FROM gateway_identity_onboarding_exchanges
WHERE id=$1::uuid`, created.ID).Scan(&persisted); err != nil {
t.Fatalf("read persisted binding error category: %v", err)
}
if persisted != category {
t.Fatalf("persisted binding error category=%q, want %q", persisted, category)
}
}
func assertIdentityRevisionSecretRefs(t *testing.T, ctx context.Context, db *Store, revisionID, wantMachine, wantSession string) {
t.Helper()
revision, err := db.IdentityConfigurationRevision(ctx, revisionID)
if err != nil {
t.Fatalf("read revision %s: %v", revisionID, err)
}
if revision.MachineCredentialRef != wantMachine || revision.SessionEncryptionKeyRef != wantSession {
t.Fatalf("revision %s secret refs machine=%q session=%q, want machine=%q session=%q",
revisionID, revision.MachineCredentialRef, revision.SessionEncryptionKeyRef, wantMachine, wantSession)
}
}
func seedIdentityActivationPrerequisites(t *testing.T, ctx context.Context, db *Store, tenantKey string) {
t.Helper()
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_tenants(tenant_key,name,status)
VALUES($1,$2,'active')`, tenantKey, "Identity lifecycle test tenant"); err != nil {
t.Fatalf("seed identity lifecycle tenant: %v", err)
}
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_users(user_key,username,password_hash,roles,source,status)
VALUES($1,$2,$3,'["manager"]'::jsonb,'gateway','active')`,
"identity-lifecycle-manager", "identity-lifecycle-manager", "test-only-password-hash"); err != nil {
t.Fatalf("seed identity lifecycle break-glass manager: %v", err)
}
}
func seedIdentityLifecycleRevision(
t *testing.T,
ctx context.Context,
db *Store,
state identity.RevisionState,
tenantKey string,
) string {
t.Helper()
revisionID := uuid.NewString()
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions (
id,state,auth_center_url,issuer,tenant_id,application_id,audience,browser_client_id,
local_tenant_key,public_base_url,web_base_url,validated_at,activated_at
) VALUES (
$1::uuid,$2,'https://auth.test.example','https://issuer.test.example',$3,$4,$5,$6,
$7,'https://gateway.test.example','https://gateway-web.test.example',
CASE WHEN $2 IN ('validated','active') THEN now() ELSE NULL END,
CASE WHEN $2='active' THEN now() ELSE NULL END
)`, revisionID, state, uuid.NewString(), uuid.NewString(), "urn:easyai:resource:"+revisionID,
"browser-"+revisionID, tenantKey); err != nil {
t.Fatalf("seed %s identity lifecycle revision: %v", state, err)
}
return revisionID
}
func waitForIdentityLifecycleLockWaiters(t *testing.T, ctx context.Context, db *Store, want int) {
t.Helper()
classID := int64(uint64(identityConfigurationLifecycleLockID) >> 32)
objectID := int64(uint64(identityConfigurationLifecycleLockID) & 0xffffffff)
deadline := time.Now().Add(5 * time.Second)
for {
var waiters int
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM pg_locks
WHERE locktype='advisory' AND database=(SELECT oid FROM pg_database WHERE datname=current_database())
AND classid::bigint=$1 AND objid::bigint=$2 AND objsubid=1 AND NOT granted`, classID, objectID).Scan(&waiters); err != nil {
t.Fatalf("read identity lifecycle lock waiters: %v", err)
}
if waiters >= want {
return
}
if time.Now().After(deadline) {
t.Fatalf("identity lifecycle lock waiters=%d, want at least %d", waiters, want)
}
time.Sleep(10 * time.Millisecond)
}
}
func newIdentityPairingPostgresTestStore(t *testing.T) *Store {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity pairing PostgreSQL integration tests")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect identity pairing test database: %v", err)
}
var databaseName string
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
admin.Close()
t.Fatalf("read identity pairing test database name: %v", err)
}
if !strings.Contains(strings.ToLower(databaseName), "test") {
admin.Close()
t.Fatalf("refusing to use non-test database %q", databaseName)
}
schemaName := "gateway_identity_pairing_" + strings.ReplaceAll(uuid.NewString(), "-", "")
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
admin.Close()
t.Fatalf("create identity pairing test schema: %v", err)
}
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("parse identity pairing test database URL: %v", err)
}
config.ConnConfig.RuntimeParams["search_path"] = schemaName
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("connect identity pairing test schema: %v", err)
}
t.Cleanup(func() {
pool.Close()
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
t.Errorf("drop identity pairing test schema: %v", err)
}
admin.Close()
})
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate identity pairing integration test")
}
migrationDirectory := filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
for _, migrationName := range []string{
"0001_init.sql",
"0061_oidc_server_sessions.sql",
"0067_identity_configuration_revisions.sql",
"0068_identity_onboarding_exchanges.sql",
"0069_identity_pairing_cancellation.sql",
"0070_identity_secret_cleanup_queue.sql",
"0071_identity_pairing_start_reservation.sql",
"0072_identity_secret_cleanup_claim_lifecycle.sql",
"0073_identity_pairing_start_reservation_upgrade.sql",
"0074_identity_pairing_error_categories.sql",
} {
migration, err := os.ReadFile(filepath.Join(migrationDirectory, migrationName))
if err != nil {
t.Fatalf("read %s: %v", migrationName, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin %s: %v", migrationName, err)
}
if _, err := tx.Exec(ctx, string(migration)); err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("execute %s: %v", migrationName, err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit %s: %v", migrationName, err)
}
}
return &Store{pool: pool}
}

View File

@ -0,0 +1,206 @@
package store
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestIdentitySecretCleanupClaimLifecycleUpgradeMigrationExists(t *testing.T) {
payload, err := os.ReadFile(identitySecretCleanupMigrationPath(t, "0072_identity_secret_cleanup_claim_lifecycle.sql"))
if err != nil {
t.Fatal(err)
}
content := strings.ToLower(string(payload))
for _, required := range []string{
"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 column status set default 'pending'",
"alter column status set not null",
"gateway_identity_secret_cleanup_status_check",
"gateway_identity_secret_cleanup_claim_check",
"drop index if exists idx_gateway_identity_secret_cleanup_due",
"create index idx_gateway_identity_secret_cleanup_due",
} {
if !strings.Contains(content, required) {
t.Fatalf("identity Secret cleanup lifecycle migration is missing %q", required)
}
}
for _, forbidden := range []string{"secret_value", "client_secret", "machine_secret", "token text"} {
if strings.Contains(content, forbidden) {
t.Fatalf("identity Secret cleanup lifecycle migration stores forbidden value field %q", forbidden)
}
}
}
func TestIdentitySecretCleanupClaimLifecycleUpgradeMigratesLegacyQueue(t *testing.T) {
db := newIdentitySecretCleanupMigrationPostgresStore(t)
ctx := context.Background()
reference := "identity-cleanup-legacy-" + uuid.NewString()
if _, err := db.pool.Exec(ctx, `
CREATE TABLE 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,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);`); err != nil {
t.Fatalf("create legacy identity Secret cleanup queue: %v", err)
}
if _, err := db.pool.Exec(ctx, `CREATE INDEX idx_gateway_identity_secret_cleanup_due
ON gateway_identity_secret_cleanup_queue(not_before,updated_at)`); err != nil {
t.Fatalf("create legacy identity Secret cleanup due index: %v", err)
}
if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before)
VALUES($1,now()-interval '1 minute')`, reference); err != nil {
t.Fatalf("seed legacy identity Secret cleanup row: %v", err)
}
applyIdentitySecretCleanupMigration(t, ctx, db.pool, "0072_identity_secret_cleanup_claim_lifecycle.sql")
var status string
var claimToken, leaseExpiresAt *string
if err := db.pool.QueryRow(ctx, `
SELECT status,claim_token::text,lease_expires_at::text
FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1`, reference).
Scan(&status, &claimToken, &leaseExpiresAt); err != nil {
t.Fatalf("read migrated cleanup row: %v", err)
}
if status != "pending" || claimToken != nil || leaseExpiresAt != nil {
t.Fatalf("legacy cleanup row was not backfilled to an unclaimed pending lifecycle")
}
claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil {
t.Fatalf("claim migrated cleanup row: %v", err)
}
if len(claims) != 1 || claims[0].Reference != reference || claims[0].ClaimToken == "" {
t.Fatalf("claim migrated cleanup row returned unexpected claim metadata")
}
completed, err := db.CompleteIdentitySecretCleanup(ctx, reference, claims[0].ClaimToken)
if err != nil || !completed {
t.Fatalf("complete migrated cleanup claim: completed=%t err=%v", completed, err)
}
invalidReference := "identity-cleanup-invalid-" + uuid.NewString()
_, err = db.pool.Exec(ctx, `
INSERT INTO gateway_identity_secret_cleanup_queue(
secret_ref,not_before,status,claim_token,lease_expires_at
) VALUES($1,now(),'claimed',NULL,NULL)`, invalidReference)
requireIdentitySecretCleanupMigrationCheckViolation(t, err)
var indexDefinition string
if err := db.pool.QueryRow(ctx, `
SELECT pg_get_indexdef(indexrelid)
FROM pg_index
WHERE indexrelid='idx_gateway_identity_secret_cleanup_due'::regclass`).Scan(&indexDefinition); err != nil {
t.Fatalf("read upgraded cleanup due index: %v", err)
}
if !strings.Contains(strings.ToLower(indexDefinition), "(status, not_before, lease_expires_at, updated_at)") {
t.Fatalf("cleanup due index does not cover the claim lifecycle")
}
// The production migration runner applies each version once. Reapplying here
// proves that the upgrade also accepts the already-current 0070 schema shape.
applyIdentitySecretCleanupMigration(t, ctx, db.pool, "0072_identity_secret_cleanup_claim_lifecycle.sql")
}
func newIdentitySecretCleanupMigrationPostgresStore(t *testing.T) *Store {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity Secret cleanup migration integration tests")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect identity Secret cleanup migration test database: %v", err)
}
var databaseName string
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
admin.Close()
t.Fatalf("read identity Secret cleanup migration test database name: %v", err)
}
if !strings.Contains(strings.ToLower(databaseName), "test") {
admin.Close()
t.Fatalf("refusing to use non-test database %q", databaseName)
}
schemaName := "gateway_identity_cleanup_migration_" + strings.ReplaceAll(uuid.NewString(), "-", "")
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
admin.Close()
t.Fatalf("create identity Secret cleanup migration test schema: %v", err)
}
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("parse identity Secret cleanup migration test database URL: %v", err)
}
config.ConnConfig.RuntimeParams["search_path"] = schemaName
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
admin.Close()
t.Fatalf("connect identity Secret cleanup migration test schema: %v", err)
}
t.Cleanup(func() {
pool.Close()
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
t.Errorf("drop identity Secret cleanup migration test schema: %v", err)
}
admin.Close()
})
return &Store{pool: pool}
}
func applyIdentitySecretCleanupMigration(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) {
t.Helper()
payload, err := os.ReadFile(identitySecretCleanupMigrationPath(t, name))
if err != nil {
t.Fatalf("read identity Secret cleanup migration: %v", err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin identity Secret cleanup migration: %v", err)
}
if _, err := tx.Exec(ctx, string(payload)); err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("execute identity Secret cleanup migration: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit identity Secret cleanup migration: %v", err)
}
}
func identitySecretCleanupMigrationPath(t *testing.T, name string) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate identity Secret cleanup migration test")
}
return filepath.Join(filepath.Dir(filename), "..", "..", "migrations", name)
}
func requireIdentitySecretCleanupMigrationCheckViolation(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("invalid cleanup claim lifecycle unexpectedly satisfied migration constraints")
}
var postgresError *pgconn.PgError
if !errors.As(err, &postgresError) || postgresError.Code != "23514" {
t.Fatalf("invalid cleanup claim lifecycle error=%v, want PostgreSQL check violation", err)
}
}

View File

@ -2,6 +2,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
@ -13,6 +14,7 @@ import (
var (
ErrSecurityEventConnectionNotFound = errors.New("security event connection not found")
ErrSecurityEventConnectionConflict = errors.New("security event connection conflicts with current state")
ErrIdentitySecretCleanupConflict = errors.New("identity secret cleanup conflicts with current state")
)
type SecurityEventConnection struct {
@ -44,6 +46,121 @@ type SecurityEventConnectionIdempotency struct {
Response json.RawMessage
}
type IdentitySecretCleanupClaim struct {
Reference string
ClaimToken string
LeaseExpiresAt time.Time
}
func (s *Store) QueueIdentitySecretCleanup(ctx context.Context, reference string, notBefore time.Time) error {
tag, err := s.pool.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before)
VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE
SET not_before=LEAST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now()
WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrIdentitySecretCleanupConflict
}
return nil
}
func (s *Store) RemovePendingIdentitySecretCleanup(ctx context.Context, reference string) (bool, error) {
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=$1 AND status='pending'`, reference)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *Store) ClaimIdentitySecretCleanups(ctx context.Context, limit int, lease time.Duration) ([]IdentitySecretCleanupClaim, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
if lease <= 0 || lease > time.Hour {
lease = 2 * time.Minute
}
leaseSeconds := int64((lease + time.Second - 1) / time.Second)
rows, err := s.pool.Query(ctx, `WITH candidates AS MATERIALIZED (
SELECT secret_ref FROM gateway_identity_secret_cleanup_queue
WHERE (status='pending' AND not_before <= now())
OR (status='claimed' AND lease_expires_at <= now())
ORDER BY COALESCE(lease_expires_at,not_before),created_at
FOR UPDATE SKIP LOCKED
LIMIT $1
)
UPDATE gateway_identity_secret_cleanup_queue queued
SET status='claimed',claim_token=gen_random_uuid(),
lease_expires_at=now()+make_interval(secs => $2::int),updated_at=now()
FROM candidates
WHERE queued.secret_ref=candidates.secret_ref
RETURNING queued.secret_ref,queued.claim_token::text,queued.lease_expires_at`, limit, leaseSeconds)
if err != nil {
return nil, err
}
defer rows.Close()
claims := make([]IdentitySecretCleanupClaim, 0)
for rows.Next() {
var claim IdentitySecretCleanupClaim
if err := rows.Scan(&claim.Reference, &claim.ClaimToken, &claim.LeaseExpiresAt); err != nil {
return nil, err
}
claims = append(claims, claim)
}
return claims, rows.Err()
}
func (s *Store) CompleteIdentitySecretCleanup(ctx context.Context, reference, claimToken string) (bool, error) {
if _, err := uuid.Parse(claimToken); err != nil {
return false, ErrIdentitySecretCleanupConflict
}
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=$1 AND status='claimed' AND claim_token=$2::uuid`, reference, claimToken)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func adoptPendingIdentitySecrets(ctx context.Context, tx pgx.Tx, references ...string) error {
unique := make(map[string]struct{}, len(references))
for _, reference := range references {
if reference == "" {
return ErrIdentitySecretCleanupConflict
}
unique[reference] = struct{}{}
}
values := make([]string, 0, len(unique))
for reference := range unique {
values = append(values, reference)
}
tag, err := tx.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue
WHERE secret_ref=ANY($1) AND status='pending'`, values)
if err != nil {
return err
}
if tag.RowsAffected() != int64(len(values)) {
return ErrIdentitySecretCleanupConflict
}
return nil
}
func queueIdentitySecretCleanupTx(ctx context.Context, tx pgx.Tx, reference string, notBefore time.Time) error {
tag, err := tx.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before)
VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE
SET not_before=LEAST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now()
WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrIdentitySecretCleanupConflict
}
return nil
}
func (s *Store) SecurityEventConnectionIdempotency(ctx context.Context, operation, key string) (SecurityEventConnectionIdempotency, error) {
var value SecurityEventConnectionIdempotency
err := s.pool.QueryRow(ctx, `SELECT request_hash,response FROM gateway_security_event_connection_idempotency
@ -64,20 +181,22 @@ VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, o
}
func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateSecurityEventConnectionInput) (SecurityEventConnection, error) {
var value SecurityEventConnection
err := s.pool.QueryRow(ctx, `
tx, err := s.pool.Begin(ctx)
if err != nil {
return SecurityEventConnection{}, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO gateway_security_event_connections(
connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key
) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7)
RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id::text,credential_ref,
next_credential_ref,management_client_id,management_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`,
`,
input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef,
input.ManagementClientID, input.ManagementCredentialRef, input.IdempotencyKey,
).Scan(&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID,
&value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, &value.LifecycleStatus, &value.Version,
&value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt)
)
if err != nil {
if isUniqueViolation(err) {
_ = tx.Rollback(ctx)
existing, getErr := s.SecurityEventConnection(ctx)
if getErr == nil && existing.IdempotencyKey == input.IdempotencyKey && existing.TransmitterIssuer == input.TransmitterIssuer {
return existing, nil
@ -86,7 +205,13 @@ RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id
}
return SecurityEventConnection{}, err
}
return value, nil
if err := adoptPendingIdentitySecrets(ctx, tx, input.CredentialRef, input.ManagementCredentialRef); err != nil {
return SecurityEventConnection{}, err
}
if err := tx.Commit(ctx); err != nil {
return SecurityEventConnection{}, err
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConnection, error) {
@ -126,49 +251,56 @@ WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID,
return s.SecurityEventConnection(ctx)
}
func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL,updated_at=now()
WHERE connection_id=$1::uuid`, connectionID, clientID, reference)
func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string, expectedVersion int64) (SecurityEventConnection, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
defer tx.Rollback(ctx)
var oldReference sql.NullString
var lifecycle string
var version int64
if err := tx.QueryRow(ctx, `SELECT management_credential_ref,lifecycle_status,version FROM gateway_security_event_connections
WHERE connection_id=$1::uuid FOR UPDATE`, connectionID).Scan(&oldReference, &lifecycle, &version); errors.Is(err, pgx.ErrNoRows) {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) UpdateSecurityEventConnectionLifecycle(ctx context.Context, connectionID, lifecycle string, errorCategory *string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET lifecycle_status=$2,last_error_category=$3,version=version+1,updated_at=now() WHERE connection_id=$1::uuid`, connectionID, lifecycle, errorCategory)
if err != nil {
} else if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
if version != expectedVersion || lifecycle == "retiring" || lifecycle == "disconnect_pending" {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET next_credential_ref=$2,lifecycle_status='rotating',last_error_category=NULL,version=version+1,updated_at=now()
WHERE connection_id=$1::uuid`, connectionID, reference)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) PromoteSecurityEventCredential(ctx context.Context, connectionID, nextReference string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET credential_ref=$2,next_credential_ref=NULL,lifecycle_status='bootstrap',last_error_category=NULL,
tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections
SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL,
version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND next_credential_ref=$2`, connectionID, nextReference)
WHERE connection_id=$1::uuid AND version=$4
AND lifecycle_status NOT IN ('retiring','disconnect_pending')`, connectionID, clientID, reference, expectedVersion)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
if err := adoptPendingIdentitySecrets(ctx, tx, reference); err != nil {
return SecurityEventConnection{}, err
}
if oldReference.Valid && oldReference.String != reference {
if err := queueIdentitySecretCleanupTx(ctx, tx, oldReference.String, time.Now().UTC()); err != nil {
return SecurityEventConnection{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return SecurityEventConnection{}, err
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) TransitionSecurityEventConnectionLifecycle(ctx context.Context, connectionID, expectedLifecycle string, expectedVersion int64, lifecycle string, errorCategory *string) (SecurityEventConnection, error) {
tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections
SET lifecycle_status=$4,last_error_category=$5,version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND lifecycle_status=$2 AND version=$3
AND (lifecycle_status NOT IN ('retiring','disconnect_pending')
OR $4 IN ('retiring','disconnect_pending'))`,
connectionID, expectedLifecycle, expectedVersion, lifecycle, errorCategory)
if err != nil {
return SecurityEventConnection{}, err
}
@ -178,16 +310,168 @@ WHERE connection_id=$1::uuid AND next_credential_ref=$2`, connectionID, nextRefe
return s.SecurityEventConnection(ctx)
}
func (s *Store) DeleteSecurityEventConnection(ctx context.Context, connectionID string) error {
if _, err := uuid.Parse(connectionID); err != nil {
return ErrSecurityEventConnectionNotFound
func (s *Store) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string, expectedVersion int64) (SecurityEventConnection, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return SecurityEventConnection{}, err
}
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections
SET next_credential_ref=$2,lifecycle_status='rotating',last_error_category=NULL,version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND version=$3 AND next_credential_ref IS NULL
AND lifecycle_status IN ('connecting','verifying','bootstrap','enabled','degraded','error')`, connectionID, reference, expectedVersion)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
if err := adoptPendingIdentitySecrets(ctx, tx, reference); err != nil {
return SecurityEventConnection{}, err
}
if err := tx.Commit(ctx); err != nil {
return SecurityEventConnection{}, err
}
return s.SecurityEventConnection(ctx)
}
func (s *Store) PromoteSecurityEventCredential(ctx context.Context, connectionID, nextReference string) (SecurityEventConnection, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return SecurityEventConnection{}, err
}
defer tx.Rollback(ctx)
var oldReference string
if err := tx.QueryRow(ctx, `SELECT credential_ref FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND next_credential_ref=$2 AND lifecycle_status='rotating' FOR UPDATE`, connectionID, nextReference).Scan(&oldReference); errors.Is(err, pgx.ErrNoRows) {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
} else if err != nil {
return SecurityEventConnection{}, err
}
tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections
SET credential_ref=$2,next_credential_ref=NULL,lifecycle_status='bootstrap',last_error_category=NULL,
version=version+1,updated_at=now()
WHERE connection_id=$1::uuid AND next_credential_ref=$2 AND lifecycle_status='rotating'`, connectionID, nextReference)
if err != nil {
return SecurityEventConnection{}, err
}
if tag.RowsAffected() == 0 {
return SecurityEventConnection{}, ErrSecurityEventConnectionConflict
}
if oldReference != nextReference {
if err := queueIdentitySecretCleanupTx(ctx, tx, oldReference, time.Now().UTC()); err != nil {
return SecurityEventConnection{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return SecurityEventConnection{}, err
}
return s.SecurityEventConnection(ctx)
}
// DiscardPreparedSecurityEventConnection atomically retires the local Secrets
// and removes only the exact, still-unbound connection generation observed by
// the caller. A concurrent Bind or credential update changes the version and
// therefore preserves both the connection and its Secrets.
func (s *Store) DiscardPreparedSecurityEventConnection(ctx context.Context, connectionID, ownerKey string, expectedVersion int64) error {
if _, err := uuid.Parse(connectionID); err != nil || ownerKey == "" || expectedVersion <= 0 {
return ErrSecurityEventConnectionConflict
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrSecurityEventConnectionNotFound
defer tx.Rollback(ctx)
var credentialReference string
var nextReference, managementReference sql.NullString
err = tx.QueryRow(ctx, `SELECT credential_ref,next_credential_ref,management_credential_ref
FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND idempotency_key=$2 AND version=$3 AND stream_id IS NULL
FOR UPDATE`, connectionID, ownerKey, expectedVersion).Scan(
&credentialReference, &nextReference, &managementReference,
)
if errors.Is(err, pgx.ErrNoRows) {
return ErrSecurityEventConnectionConflict
}
return nil
if err != nil {
return err
}
references := map[string]struct{}{credentialReference: {}}
if nextReference.Valid {
references[nextReference.String] = struct{}{}
}
if managementReference.Valid {
references[managementReference.String] = struct{}{}
}
now := time.Now().UTC()
for reference := range references {
if err := queueIdentitySecretCleanupTx(ctx, tx, reference, now); err != nil {
return err
}
}
tag, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND idempotency_key=$2 AND version=$3 AND stream_id IS NULL`,
connectionID, ownerKey, expectedVersion)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrSecurityEventConnectionConflict
}
return tx.Commit(ctx)
}
// FinalizeRetiringSecurityEventConnection atomically transfers every Secret
// reference owned by the exact retiring generation to the durable cleanup
// queue before removing that generation. The SecretStore worker can therefore
// resume after a crash without the database losing its last cleanup intent.
func (s *Store) FinalizeRetiringSecurityEventConnection(ctx context.Context, connectionID string, expectedVersion int64) error {
if _, err := uuid.Parse(connectionID); err != nil || expectedVersion <= 0 {
return ErrSecurityEventConnectionConflict
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var credentialReference string
var nextReference, managementReference sql.NullString
err = tx.QueryRow(ctx, `SELECT credential_ref,next_credential_ref,management_credential_ref
FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND lifecycle_status='retiring' AND version=$2
FOR UPDATE`, connectionID, expectedVersion).Scan(
&credentialReference, &nextReference, &managementReference,
)
if errors.Is(err, pgx.ErrNoRows) {
return ErrSecurityEventConnectionConflict
}
if err != nil {
return err
}
references := map[string]struct{}{credentialReference: {}}
if nextReference.Valid {
references[nextReference.String] = struct{}{}
}
if managementReference.Valid {
references[managementReference.String] = struct{}{}
}
now := time.Now().UTC()
for reference := range references {
if err := queueIdentitySecretCleanupTx(ctx, tx, reference, now); err != nil {
return err
}
}
tag, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_connections
WHERE connection_id=$1::uuid AND lifecycle_status='retiring' AND version=$2`, connectionID, expectedVersion)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return ErrSecurityEventConnectionConflict
}
return tx.Commit(ctx)
}

View File

@ -0,0 +1,665 @@
package store
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestSecurityEventCredentialReferenceSwitchesRemainCrashRecoverable(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
nextReference := "ssf-push-next-" + uuid.NewString()
newManagementReference := "ssf-management-next-" + uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`,
[]string{pushReference, managementReference, nextReference, newManagementReference})
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
assertIdentitySecretQueueState(t, ctx, db, pushReference, false)
assertIdentitySecretQueueState(t, ctx, db, managementReference, false)
if err := db.QueueIdentitySecretCleanup(ctx, newManagementReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := db.SetSecurityEventManagementCredential(ctx, connectionID, "machine-client", newManagementReference, connection.Version); err != nil {
t.Fatal(err)
}
assertIdentitySecretQueueState(t, ctx, db, newManagementReference, false)
assertIdentitySecretQueueState(t, ctx, db, managementReference, true)
if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
connection, err = db.SecurityEventConnection(ctx)
if err != nil {
t.Fatal(err)
}
connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version)
if err != nil {
t.Fatal(err)
}
assertIdentitySecretQueueState(t, ctx, db, nextReference, false)
if _, err := db.PromoteSecurityEventCredential(ctx, connection.ConnectionID, nextReference); err != nil {
t.Fatal(err)
}
assertIdentitySecretQueueState(t, ctx, db, pushReference, true)
}
func TestDiscardPreparedSecurityEventConnectionQueuesAllSecretsAtomically(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
ownerKey := "identity-pairing-ssf-" + uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
nextReference := "ssf-push-next-" + uuid.NewString()
references := []string{pushReference, managementReference, nextReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: ownerKey,
})
if err != nil {
t.Fatal(err)
}
if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version)
if err != nil {
t.Fatal(err)
}
if err := db.DiscardPreparedSecurityEventConnection(ctx, connectionID, ownerKey, connection.Version); err != nil {
t.Fatal(err)
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("discarded prepared connection still exists: %v", err)
}
for _, reference := range references {
assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending")
}
}
func TestConcurrentPreparedDiscardAndBindPreserveTheWinningGeneration(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
ownerKey := "identity-pairing-ssf-" + uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
references := []string{pushReference, managementReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range references {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: ownerKey,
})
if err != nil {
t.Fatal(err)
}
type result struct {
operation string
err error
}
start := make(chan struct{})
results := make(chan result, 2)
audience := "urn:easyai:ssf:receiver:" + uuid.NewString()
streamID := uuid.NewString()
go func() {
<-start
_, err := db.BindSecurityEventConnection(ctx, connectionID, audience, streamID, "verifying", connection.Version)
results <- result{operation: "bind", err: err}
}()
go func() {
<-start
err := db.DiscardPreparedSecurityEventConnection(ctx, connectionID, ownerKey, connection.Version)
results <- result{operation: "discard", err: err}
}()
close(start)
outcomes := map[string]error{}
for range 2 {
outcome := <-results
outcomes[outcome.operation] = outcome.err
}
bindWon := outcomes["bind"] == nil
discardWon := outcomes["discard"] == nil
if bindWon == discardWon {
t.Fatalf("concurrent outcomes bind=%v discard=%v, want exactly one winner", outcomes["bind"], outcomes["discard"])
}
loser := outcomes["bind"]
if bindWon {
loser = outcomes["discard"]
}
if !errors.Is(loser, ErrSecurityEventConnectionConflict) {
t.Fatalf("concurrent loser error=%v, want connection conflict", loser)
}
if bindWon {
persisted, err := db.SecurityEventConnection(ctx)
if err != nil || persisted.StreamID == nil || *persisted.StreamID != streamID {
t.Fatalf("winning bound generation was not preserved: connection=%+v err=%v", persisted, err)
}
for _, reference := range references {
assertIdentitySecretQueueState(t, ctx, db, reference, false)
}
return
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("winning discard left a connection behind: %v", err)
}
for _, reference := range references {
assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending")
}
}
func TestConcurrentSecurityEventNextCredentialsAdoptOnlyOneStagedSecret(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
candidates := []string{
"ssf-push-next-" + uuid.NewString(),
"ssf-push-next-" + uuid.NewString(),
}
allReferences := append([]string{pushReference, managementReference}, candidates...)
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, allReferences)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
for _, reference := range candidates {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
type result struct {
reference string
err error
}
start := make(chan struct{})
results := make(chan result, len(candidates))
var workers sync.WaitGroup
for _, reference := range candidates {
workers.Add(1)
go func(reference string) {
defer workers.Done()
<-start
_, err := db.SetSecurityEventNextCredential(ctx, connectionID, reference, connection.Version)
results <- result{reference: reference, err: err}
}(reference)
}
close(start)
workers.Wait()
close(results)
var adopted, pending string
for result := range results {
switch {
case result.err == nil:
if adopted != "" {
t.Fatal("more than one concurrent next credential was adopted")
}
adopted = result.reference
case errors.Is(result.err, ErrSecurityEventConnectionConflict):
pending = result.reference
default:
t.Fatalf("concurrent next credential returned unexpected error: %v", result.err)
}
}
if adopted == "" || pending == "" {
t.Fatalf("concurrent next credential outcomes adopted=%t pending=%t", adopted != "", pending != "")
}
connection, err = db.SecurityEventConnection(ctx)
if err != nil || connection.NextCredentialRef == nil || *connection.NextCredentialRef != adopted {
t.Fatalf("connection did not retain the sole adopted next credential: %v", err)
}
assertIdentitySecretQueueState(t, ctx, db, adopted, false)
assertIdentitySecretQueueStatus(t, ctx, db, pending, "pending")
}
func TestRetiringSecurityEventConnectionCannotAdoptManagementSecret(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
newManagementReference := "ssf-management-next-" + uuid.NewString()
allReferences := []string{pushReference, managementReference, newManagementReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, allReferences)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
connection, err = db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, connection.LifecycleStatus, connection.Version, "retiring", nil,
)
if err != nil {
t.Fatal(err)
}
if err := db.QueueIdentitySecretCleanup(ctx, newManagementReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := db.SetSecurityEventManagementCredential(ctx, connectionID, "machine-client", newManagementReference, connection.Version); !errors.Is(err, ErrSecurityEventConnectionConflict) {
t.Fatalf("retiring connection management adoption error=%v, want conflict", err)
}
connection, err = db.SecurityEventConnection(ctx)
if err != nil || connection.ManagementCredentialRef == nil || *connection.ManagementCredentialRef != managementReference {
t.Fatalf("retiring connection changed its management credential: %v", err)
}
assertIdentitySecretQueueStatus(t, ctx, db, newManagementReference, "pending")
}
func TestSecurityEventRetirementRequiresTheCurrentConnectionGeneration(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-" + connectionID
managementReference := "ssf-management-" + connectionID
references := []string{pushReference, managementReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range references {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
if _, err := db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, "connecting", connection.Version-1, "retiring", nil,
); !errors.Is(err, ErrSecurityEventConnectionConflict) {
t.Fatalf("stale retirement transition error=%v, want conflict", err)
}
current, err := db.SecurityEventConnection(ctx)
if err != nil || current.LifecycleStatus != "connecting" || current.Version != connection.Version {
t.Fatalf("stale transition changed connection generation: lifecycle=%q version=%d err=%v",
current.LifecycleStatus, current.Version, err)
}
current, err = db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, "connecting", connection.Version, "retiring", nil,
)
if err != nil {
t.Fatal(err)
}
if _, err := db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, "retiring", current.Version, "error", nil,
); !errors.Is(err, ErrSecurityEventConnectionConflict) {
t.Fatalf("retiring generation revival error=%v, want conflict", err)
}
if err := db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, connection.Version); !errors.Is(err, ErrSecurityEventConnectionConflict) {
t.Fatalf("stale retirement deletion error=%v, want conflict", err)
}
if persisted, err := db.SecurityEventConnection(ctx); err != nil || persisted.LifecycleStatus != "retiring" || persisted.Version != current.Version {
t.Fatalf("stale retirement deletion removed current generation: lifecycle=%q version=%d err=%v",
persisted.LifecycleStatus, persisted.Version, err)
}
if err := db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, current.Version); err != nil {
t.Fatal(err)
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("current retiring generation was not deleted: %v", err)
}
for _, reference := range references {
assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending")
}
}
func TestConcurrentSecurityEventRetirementFinalizationQueuesOneGenerationAtomically(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-retire-worker-" + connectionID
managementReference := "ssf-management-retire-worker-" + connectionID
nextReference := "ssf-push-next-retire-worker-" + uuid.NewString()
references := []string{pushReference, managementReference, nextReference}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil {
t.Fatal(err)
}
for _, reference := range []string{pushReference, managementReference} {
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
}
connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if err != nil {
t.Fatal(err)
}
if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version)
if err != nil {
t.Fatal(err)
}
connection, err = db.TransitionSecurityEventConnectionLifecycle(
ctx, connectionID, connection.LifecycleStatus, connection.Version, "retiring", nil,
)
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
results := make(chan error, 2)
var workers sync.WaitGroup
for range 2 {
workers.Add(1)
go func() {
defer workers.Done()
<-start
results <- db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, connection.Version)
}()
}
close(start)
workers.Wait()
close(results)
succeeded, conflicted := 0, 0
for err := range results {
switch {
case err == nil:
succeeded++
case errors.Is(err, ErrSecurityEventConnectionConflict):
conflicted++
default:
t.Fatalf("unexpected concurrent finalization error: %v", err)
}
}
if succeeded != 1 || conflicted != 1 {
t.Fatalf("concurrent finalization succeeded=%d conflicted=%d, want 1/1", succeeded, conflicted)
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("finalized connection still exists: %v", err)
}
for _, reference := range references {
assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending")
}
}
func TestClaimedStagingSecretsCannotBeAdoptedByAConnection(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
connectionID := uuid.NewString()
pushReference := "ssf-push-claimed-" + uuid.NewString()
managementReference := "ssf-management-claimed-" + uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`,
[]string{pushReference, managementReference})
})
if err := db.QueueIdentitySecretCleanup(ctx, pushReference, time.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
if err := db.QueueIdentitySecretCleanup(ctx, managementReference, time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil || len(claims) != 1 || claims[0].Reference != managementReference {
t.Fatalf("claim staging management Secret: count=%d reference_matches=%t err=%v",
len(claims), len(claims) == 1 && claims[0].Reference == managementReference, err)
}
_, err = db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{
ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf",
EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference,
ManagementClientID: "machine-client", ManagementCredentialRef: managementReference,
IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(),
})
if !errors.Is(err, ErrIdentitySecretCleanupConflict) {
t.Fatalf("claimed Secret adoption error=%v, want cleanup conflict", err)
}
if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) {
t.Fatalf("failed adoption committed connection: %v", err)
}
assertIdentitySecretQueueStatus(t, ctx, db, pushReference, "pending")
assertIdentitySecretQueueStatus(t, ctx, db, managementReference, "claimed")
}
func TestIdentitySecretCleanupCompletionRejectsExpiredClaimABA(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
reference := "ssf-management-aba-" + uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1`, reference)
})
if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
first, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil || len(first) != 1 {
t.Fatalf("first claim count=%d err=%v", len(first), err)
}
if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_secret_cleanup_queue SET lease_expires_at=now()-interval '1 second' WHERE secret_ref=$1`, reference); err != nil {
t.Fatal(err)
}
second, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute)
if err != nil || len(second) != 1 || second[0].ClaimToken == first[0].ClaimToken {
t.Fatalf("second claim count=%d token_rotated=%t err=%v",
len(second), len(second) == 1 && second[0].ClaimToken != first[0].ClaimToken, err)
}
completed, err := db.CompleteIdentitySecretCleanup(ctx, reference, first[0].ClaimToken)
if err != nil || completed {
t.Fatalf("stale completion completed=%t err=%v", completed, err)
}
assertIdentitySecretQueueStatus(t, ctx, db, reference, "claimed")
completed, err = db.CompleteIdentitySecretCleanup(ctx, reference, second[0].ClaimToken)
if err != nil || !completed {
t.Fatalf("current completion completed=%t err=%v", completed, err)
}
}
func TestConcurrentIdentitySecretCleanupClaimsAreDisjoint(t *testing.T) {
db := newSecurityEventSecretCleanupPostgresStore(t)
ctx := context.Background()
references := make([]string, 20)
for index := range references {
references[index] = "ssf-cleanup-concurrent-" + uuid.NewString()
if err := db.QueueIdentitySecretCleanup(ctx, references[index], time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references)
})
start := make(chan struct{})
results := make(chan []IdentitySecretCleanupClaim, 2)
errorsCh := make(chan error, 2)
var workers sync.WaitGroup
for range 2 {
workers.Add(1)
go func() {
defer workers.Done()
<-start
claims, err := db.ClaimIdentitySecretCleanups(ctx, len(references), time.Minute)
results <- claims
errorsCh <- err
}()
}
close(start)
workers.Wait()
close(results)
close(errorsCh)
for err := range errorsCh {
if err != nil {
t.Fatal(err)
}
}
claimed := map[string]string{}
for claims := range results {
for _, claim := range claims {
if previous := claimed[claim.Reference]; previous != "" {
t.Fatalf("Secret %q was claimed by more than one worker", claim.Reference)
}
claimed[claim.Reference] = claim.ClaimToken
}
}
if len(claimed) != len(references) {
t.Fatalf("claimed %d Secrets, want %d", len(claimed), len(references))
}
}
func newSecurityEventSecretCleanupPostgresStore(t *testing.T) *Store {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity Secret cleanup PostgreSQL integration tests")
}
ctx := context.Background()
probe, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
var databaseName string
if err := probe.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
probe.Close()
t.Fatal(err)
}
probe.Close()
if !strings.Contains(strings.ToLower(databaseName), "test") {
t.Fatalf("refusing to migrate non-test database %q", databaseName)
}
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
return db
}
func assertIdentitySecretQueueState(t *testing.T, ctx context.Context, db *Store, reference string, expected bool) {
t.Helper()
var exists bool
if err := db.pool.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1)`, reference).Scan(&exists); err != nil {
t.Fatal(err)
}
if exists != expected {
t.Fatalf("Secret cleanup reference %q exists=%t want=%t", reference, exists, expected)
}
}
func assertIdentitySecretQueueStatus(t *testing.T, ctx context.Context, db *Store, reference, expected string) {
t.Helper()
var status string
if err := db.pool.QueryRow(ctx, `SELECT status FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1`, reference).Scan(&status); err != nil {
t.Fatal(err)
}
if status != expected {
t.Fatalf("Secret cleanup reference %q status=%q want=%q", reference, status, expected)
}
}

View File

@ -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';

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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'
));

View File

@ -1035,19 +1035,25 @@ export type IdentityPairingStatus =
| 'credentials_saved'
| 'completed'
| 'failed'
| 'expired';
| 'expired'
| 'cancelled';
export type IdentityPairingCleanupStatus = 'none' | 'pending' | 'completed';
export interface IdentityPairingExchange {
id: string;
revisionId: string;
remoteExchangeId: string;
status: IdentityPairingStatus;
cleanupStatus: IdentityPairingCleanupStatus;
remoteVersion: number;
expiresAt: string;
version: number;
lastErrorCategory?: string;
authCenterAuditId?: string;
lastTraceId?: string;
cancelledAt?: string;
cleanupCompletedAt?: string;
createdAt: string;
updatedAt: string;
}