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:
parent
cdfca61304
commit
a312ad880d
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) }
|
||||
|
||||
126
apps/api/internal/httpapi/identity_pairing_coordinator.go
Normal file
126
apps/api/internal/httpapi/identity_pairing_coordinator.go
Normal 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
|
||||
})
|
||||
}
|
||||
150
apps/api/internal/httpapi/identity_pairing_coordinator_test.go
Normal file
150
apps/api/internal/httpapi/identity_pairing_coordinator_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
26
apps/api/internal/httpapi/local_login_policy_test.go
Normal file
26
apps/api/internal/httpapi/local_login_policy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"`)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
56
apps/api/internal/httpapi/security_events_test.go
Normal file
56
apps/api/internal/httpapi/security_events_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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)
|
||||
}
|
||||
}
|
||||
79
apps/api/internal/securityevents/retirement_worker.go
Normal file
79
apps/api/internal/securityevents/retirement_worker.go
Normal 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)
|
||||
}
|
||||
178
apps/api/internal/securityevents/retirement_worker_test.go
Normal file
178
apps/api/internal/securityevents/retirement_worker_test.go
Normal 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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
715
apps/api/internal/store/identity_pairing_integration_test.go
Normal file
715
apps/api/internal/store/identity_pairing_integration_test.go
Normal 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}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
69
apps/api/migrations/0069_identity_pairing_cancellation.sql
Normal file
69
apps/api/migrations/0069_identity_pairing_cancellation.sql
Normal 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';
|
||||
19
apps/api/migrations/0070_identity_secret_cleanup_queue.sql
Normal file
19
apps/api/migrations/0070_identity_secret_cleanup_queue.sql
Normal 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);
|
||||
@ -0,0 +1,29 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_identity_pairing_start_reservation (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
attempt_id uuid NOT NULL UNIQUE,
|
||||
state text NOT NULL DEFAULT 'starting' CHECK (state IN ('starting','paired')),
|
||||
revision_id uuid UNIQUE REFERENCES gateway_identity_configuration_revisions(id) ON DELETE RESTRICT,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT gateway_identity_pairing_start_state_check CHECK (
|
||||
(state='starting' AND revision_id IS NULL) OR
|
||||
(state='paired' AND revision_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_identity_pairing_start_expiry
|
||||
ON gateway_identity_pairing_start_reservation(state,expires_at);
|
||||
|
||||
INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,revision_id,expires_at)
|
||||
SELECT true,exchange.id,'paired',revision.id,exchange.expires_at
|
||||
FROM gateway_identity_configuration_revisions revision
|
||||
JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id
|
||||
WHERE revision.state IN ('draft','validated','failed')
|
||||
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
|
||||
ORDER BY revision.created_at DESC,exchange.created_at DESC
|
||||
LIMIT 1
|
||||
ON CONFLICT DO NOTHING;
|
||||
@ -0,0 +1,34 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
ALTER TABLE gateway_identity_secret_cleanup_queue
|
||||
ADD COLUMN IF NOT EXISTS status text,
|
||||
ADD COLUMN IF NOT EXISTS claim_token uuid,
|
||||
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
|
||||
|
||||
ALTER TABLE gateway_identity_secret_cleanup_queue
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_secret_cleanup_status_check,
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_secret_cleanup_claim_check;
|
||||
|
||||
UPDATE gateway_identity_secret_cleanup_queue
|
||||
SET status='pending',claim_token=NULL,lease_expires_at=NULL,updated_at=now()
|
||||
WHERE status IS NULL
|
||||
OR status NOT IN ('pending','claimed')
|
||||
OR (status='pending' AND (claim_token IS NOT NULL OR lease_expires_at IS NOT NULL))
|
||||
OR (status='claimed' AND (claim_token IS NULL OR lease_expires_at IS NULL));
|
||||
|
||||
ALTER TABLE gateway_identity_secret_cleanup_queue
|
||||
ALTER COLUMN status SET DEFAULT 'pending',
|
||||
ALTER COLUMN status SET NOT NULL,
|
||||
ADD CONSTRAINT gateway_identity_secret_cleanup_status_check
|
||||
CHECK (status IN ('pending','claimed')),
|
||||
ADD CONSTRAINT gateway_identity_secret_cleanup_claim_check
|
||||
CHECK (
|
||||
(status='pending' AND claim_token IS NULL AND lease_expires_at IS NULL) OR
|
||||
(status='claimed' AND claim_token IS NOT NULL AND lease_expires_at IS NOT NULL)
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS idx_gateway_identity_secret_cleanup_due;
|
||||
|
||||
CREATE INDEX idx_gateway_identity_secret_cleanup_due
|
||||
ON gateway_identity_secret_cleanup_queue(status,not_before,lease_expires_at,updated_at);
|
||||
@ -0,0 +1,89 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
-- 0071 was briefly released with only the singleton reservation lease fields.
|
||||
-- Add the durable lifecycle columns without rewriting that already-applied
|
||||
-- migration, then reconcile the singleton with the canonical outstanding
|
||||
-- pairing (when one exists).
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
ADD COLUMN IF NOT EXISTS state text,
|
||||
ADD COLUMN IF NOT EXISTS revision_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS updated_at timestamptz;
|
||||
|
||||
UPDATE gateway_identity_pairing_start_reservation
|
||||
SET state=CASE WHEN revision_id IS NULL THEN 'starting' ELSE 'paired' END,
|
||||
updated_at=COALESCE(updated_at,created_at,now())
|
||||
WHERE state IS NULL
|
||||
OR state NOT IN ('starting','paired')
|
||||
OR (state='starting' AND revision_id IS NOT NULL)
|
||||
OR (state='paired' AND revision_id IS NULL)
|
||||
OR updated_at IS NULL;
|
||||
|
||||
WITH canonical_pairing AS (
|
||||
SELECT exchange.id AS attempt_id,
|
||||
revision.id AS revision_id,
|
||||
exchange.expires_at
|
||||
FROM gateway_identity_configuration_revisions revision
|
||||
JOIN gateway_identity_onboarding_exchanges exchange
|
||||
ON exchange.revision_id=revision.id
|
||||
WHERE revision.state IN ('draft','validated','failed')
|
||||
AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed')
|
||||
ORDER BY revision.created_at DESC,exchange.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
INSERT INTO gateway_identity_pairing_start_reservation(
|
||||
singleton,attempt_id,state,revision_id,expires_at,updated_at
|
||||
)
|
||||
SELECT true,attempt_id,'paired',revision_id,expires_at,now()
|
||||
FROM canonical_pairing
|
||||
ON CONFLICT (singleton) DO UPDATE
|
||||
SET attempt_id=EXCLUDED.attempt_id,
|
||||
state=EXCLUDED.state,
|
||||
revision_id=EXCLUDED.revision_id,
|
||||
expires_at=EXCLUDED.expires_at,
|
||||
updated_at=now();
|
||||
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
ALTER COLUMN state SET DEFAULT 'starting',
|
||||
ALTER COLUMN state SET NOT NULL,
|
||||
ALTER COLUMN updated_at SET DEFAULT now(),
|
||||
ALTER COLUMN updated_at SET NOT NULL;
|
||||
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_pairing_start_state_value_check,
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_pairing_start_state_check;
|
||||
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
ADD CONSTRAINT gateway_identity_pairing_start_state_value_check
|
||||
CHECK (state IN ('starting','paired')),
|
||||
ADD CONSTRAINT gateway_identity_pairing_start_state_check CHECK (
|
||||
(state='starting' AND revision_id IS NULL) OR
|
||||
(state='paired' AND revision_id IS NOT NULL)
|
||||
);
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conrelid='gateway_identity_pairing_start_reservation'::regclass
|
||||
AND contype='f'
|
||||
AND pg_get_constraintdef(oid) LIKE 'FOREIGN KEY (revision_id)%'
|
||||
) THEN
|
||||
ALTER TABLE gateway_identity_pairing_start_reservation
|
||||
ADD CONSTRAINT gateway_identity_pairing_start_revision_fk
|
||||
FOREIGN KEY (revision_id)
|
||||
REFERENCES gateway_identity_configuration_revisions(id)
|
||||
ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_identity_pairing_start_revision_unique
|
||||
ON gateway_identity_pairing_start_reservation(revision_id)
|
||||
WHERE revision_id IS NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_gateway_identity_pairing_start_expiry;
|
||||
|
||||
CREATE INDEX idx_gateway_identity_pairing_start_expiry
|
||||
ON gateway_identity_pairing_start_reservation(state,expires_at);
|
||||
@ -0,0 +1,32 @@
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '60s';
|
||||
|
||||
-- Pairing recovery added explicit, redacted error categories after 0069 was
|
||||
-- released. Keep the database allow-list in lockstep so PostgreSQL can persist
|
||||
-- the reason an administrator must resolve instead of hiding it behind a CHECK
|
||||
-- violation.
|
||||
ALTER TABLE gateway_identity_onboarding_exchanges
|
||||
DROP CONSTRAINT IF EXISTS gateway_identity_onboarding_error_category_check;
|
||||
|
||||
ALTER TABLE gateway_identity_onboarding_exchanges
|
||||
ADD CONSTRAINT gateway_identity_onboarding_error_category_check
|
||||
CHECK (last_error_category IS NULL OR last_error_category IN (
|
||||
'metadata_submission_failed','exchange_status_unavailable','credential_delivery_failed',
|
||||
'exchange_completion_failed','exchange_expired','remote_exchange_failed','remote_state_invalid',
|
||||
'security_event_configuration_invalid','security_event_connection_conflict',
|
||||
'security_event_discovery_failed','security_event_management_token_failed',
|
||||
'security_event_stream_create_failed','security_event_stream_response_invalid',
|
||||
'security_event_receiver_activation_failed','security_event_preparation_failed',
|
||||
'security_event_retirement_pending','security_event_credential_handoff_unsafe',
|
||||
'security_event_connection_binding_missing','security_event_connection_binding_unavailable',
|
||||
'security_event_connection_binding_invalid','security_event_connection_binding_mismatch',
|
||||
'pairing_step_failed',
|
||||
'cleanup_revision_unavailable','cleanup_security_event_unavailable',
|
||||
'cleanup_security_event_configuration_invalid','cleanup_security_event_connection_conflict',
|
||||
'cleanup_security_event_discovery_failed','cleanup_security_event_management_token_failed',
|
||||
'cleanup_security_event_stream_create_failed','cleanup_security_event_stream_response_invalid',
|
||||
'cleanup_security_event_receiver_activation_failed','cleanup_security_event_preparation_failed',
|
||||
'cleanup_security_event_retirement_pending','cleanup_security_event_connection_cleanup_failed',
|
||||
'cleanup_security_event_secret_cleanup_failed','cleanup_security_event_credential_handoff_unsafe',
|
||||
'cleanup_security_event_failed','cleanup_secret_store_failed','cleanup_finalize_failed'
|
||||
));
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user