feat(identity): 接入认证中心多租户登录

支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
2026-07-28 17:28:35 +08:00
parent 0b02e62c72
commit 5c679ff13f
45 changed files with 2986 additions and 139 deletions
@@ -21,10 +21,11 @@ type IdentityManagementRequest struct {
}
const identityRevisionColumns = `
id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''),
id::text,state,schema_version,tenant_mode,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''),
COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix,
local_tenant_key,public_base_url,web_base_url,jit_enabled,legacy_jwt_enabled,token_introspection,session_revocation,
COALESCE(security_event_issuer,''),COALESCE(security_event_configuration_url,''),COALESCE(security_event_audience,''),
COALESCE(tenant_context_endpoint,''),COALESCE(tenant_context_audience,''),COALESCE(tenant_context_scope,''),
COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),session_idle_seconds,session_absolute_seconds,
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`
@@ -83,11 +84,11 @@ func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revisio
capabilities, _ := json.Marshal(revision.Capabilities)
return scanIdentityRevision(s.pool.QueryRow(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,
id,state,schema_version,tenant_mode,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
) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15)
) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13::jsonb,$14,$15,$16)
RETURNING `+identityRevisionColumns,
revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix,
revision.ID, revision.State, revision.SchemaVersion, revision.TenantMode, revision.AuthCenterURL, revision.RolePrefix,
revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled,
string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds, revision.SessionRefreshSeconds,
))
@@ -198,16 +199,19 @@ FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, id)
capabilities, _ := json.Marshal(updated.Capabilities)
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,
security_event_issuer=NULLIF($13,''),security_event_configuration_url=NULLIF($14,''),security_event_audience=NULLIF($15,''),
machine_credential_ref=NULLIF($16,''),session_encryption_key_ref=NULLIF($17,''),last_trace_id=NULLIF($18,''),
last_audit_id=NULLIF($19,''),last_error_category=NULL,version=version+1,updated_at=now()
schema_version=$3,tenant_mode=$4,issuer=$5,tenant_id=NULLIF($6,''),application_id=$7,audience=NULLIF($8,''),
browser_client_id=NULLIF($9,''),machine_client_id=NULLIF($10,''),local_tenant_key=$11,
scopes=$12::jsonb,capabilities=$13::jsonb,token_introspection=$14,session_revocation=$15,
security_event_issuer=NULLIF($16,''),security_event_configuration_url=NULLIF($17,''),security_event_audience=NULLIF($18,''),
tenant_context_endpoint=NULLIF($19,''),tenant_context_audience=NULLIF($20,''),tenant_context_scope=NULLIF($21,''),
machine_credential_ref=NULLIF($22,''),session_encryption_key_ref=NULLIF($23,''),last_trace_id=NULLIF($24,''),
last_audit_id=NULLIF($25,''),last_error_category=NULL,version=version+1,updated_at=now()
WHERE id=$1::uuid AND version=$2 AND state='draft'
RETURNING `+identityRevisionColumns,
id, expectedVersion, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience,
updated.BrowserClientID, updated.MachineClientID, string(scopes), string(capabilities), updated.TokenIntrospection,
id, expectedVersion, updated.SchemaVersion, updated.TenantMode, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience,
updated.BrowserClientID, updated.MachineClientID, updated.LocalTenantKey, string(scopes), string(capabilities), updated.TokenIntrospection,
updated.SessionRevocation, updated.SecurityEventIssuer, updated.SecurityEventConfigURL, updated.SecurityEventAudience,
updated.TenantContextEndpoint, updated.TenantContextAudience, updated.TenantContextScope,
updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID,
))
if errors.Is(err, pgx.ErrNoRows) {
@@ -296,8 +300,12 @@ func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expecte
return identity.Revision{}, false, identity.ErrBreakGlassRequired
}
var tenantExists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=(
SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$1::uuid) AND status='active')`, id).Scan(&tenantExists); err != nil {
if err := tx.QueryRow(ctx, `SELECT revision.tenant_mode='multi_tenant' OR EXISTS(
SELECT 1 FROM gateway_tenants tenant
WHERE tenant.tenant_key=revision.local_tenant_key AND tenant.status='active'
)
FROM gateway_identity_configuration_revisions revision
WHERE revision.id=$1::uuid`, id).Scan(&tenantExists); err != nil {
return identity.Revision{}, false, err
}
if !tenantExists {
@@ -428,11 +436,12 @@ func scanIdentityRevision(row scanner) (identity.Revision, error) {
var state string
var scopes, capabilities []byte
if err := row.Scan(
&revision.ID, &state, &revision.SchemaVersion, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID,
&revision.ID, &state, &revision.SchemaVersion, &revision.TenantMode, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID,
&revision.ApplicationID, &revision.Audience, &revision.BrowserClientID, &revision.MachineClientID, &scopes,
&capabilities, &revision.RolePrefix, &revision.LocalTenantKey, &revision.PublicBaseURL, &revision.WebBaseURL,
&revision.JITEnabled, &revision.LegacyJWTEnabled, &revision.TokenIntrospection, &revision.SessionRevocation,
&revision.SecurityEventIssuer, &revision.SecurityEventConfigURL, &revision.SecurityEventAudience,
&revision.TenantContextEndpoint, &revision.TenantContextAudience, &revision.TenantContextScope,
&revision.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds,
&revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory,
&revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt,
@@ -0,0 +1,261 @@
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Store) resolveOrProvisionOIDCMultiTenantUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
if input.Issuer == "" || uuid.Validate(input.ApplicationID) != nil || uuid.Validate(input.TenantID) != nil ||
input.Subject == "" || input.TenantMetadataStatus != "synced" && input.TenantMetadataStatus != "metadata_pending" {
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid multi-tenant OIDC user projection input")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err :=
loadOIDCTenantBinding(ctx, tx, input)
if errors.Is(err, pgx.ErrNoRows) {
if !input.ProvisioningEnabled {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned
}
bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err =
createOIDCTenantBinding(ctx, tx, input)
accessStatus = "active"
}
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
if accessStatus != "active" {
if input.TenantMetadataStatus != "synced" {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
}
tag, reactivateErr := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
SET access_status='active',metadata_status='synced',last_error_category=NULL,updated_at=now()
WHERE id=$1::uuid AND access_status='disabled'`, bindingID)
if reactivateErr != nil {
return ResolveOrProvisionOIDCUserResult{}, reactivateErr
}
if tag.RowsAffected() != 1 {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
}
accessStatus = "active"
}
if input.TenantMetadataStatus == "synced" {
if _, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings SET
metadata_status='synced',display_name=$2,slug=$3,metadata_version=NULLIF($4,''),
metadata_etag=NULLIF($5,''),metadata_updated_at=$6,
last_sync_at=now(),next_sync_at=now()+interval '15 minutes',sync_failure_count=0,last_error_category=NULL,updated_at=now()
WHERE id=$1::uuid AND access_status='active'`,
bindingID, input.TenantName, input.TenantSlug, input.TenantMetadataVersion,
input.TenantMetadataETag, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt),
); err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
if _, err := tx.Exec(ctx, `UPDATE gateway_tenants SET name=$2,synced_at=now(),source_updated_at=$3,updated_at=now()
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL`,
gatewayTenantID, input.TenantName, input.TenantMetadataUpdatedAt,
); err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
}
user, userBindingID, err := loadOIDCMultiTenantUser(ctx, tx, bindingID, input.Subject)
created := false
if errors.Is(err, pgx.ErrNoRows) {
if !input.ProvisioningEnabled {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned
}
user, userBindingID, created, err = s.createOIDCMultiTenantUser(
ctx, tx, bindingID, gatewayTenantID, tenantKey, groupID, input,
)
}
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
if user.Status != "active" {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
}
rolesJSON, _ := json.Marshal(input.Roles)
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,last_login_at=now(),synced_at=now(),source_updated_at=now(),updated_at=now()
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL
RETURNING `+userColumns, user.ID, input.Username, string(rolesJSON)))
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
var auditID string
if created {
subjectHash := sha256.Sum256([]byte(input.Subject))
audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
Category: "identity", Action: "identity.oidc_user.provisioned",
ActorGatewayUserID: user.ID, ActorUsername: user.Username, ActorSource: "oidc", ActorRoles: user.Roles,
TargetType: "gateway_user", TargetID: user.ID, TargetGatewayUserID: user.ID,
TargetGatewayTenantID: user.GatewayTenantID, RequestIP: input.RequestIP, UserAgent: input.UserAgent,
AfterState: map[string]any{"source": "oidc_v2", "tenantKey": user.TenantKey, "userGroupId": user.DefaultUserGroupID},
Metadata: map[string]any{
"provisioningMode": "oidc-multi-tenant-jit",
"externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16],
},
})
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
auditID = audit.ID
}
if err := tx.Commit(ctx); err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
return ResolveOrProvisionOIDCUserResult{
User: multiTenantAuthUser(user, groupKey, input, userBindingID),
Created: created, AuditID: auditID,
}, nil
}
func loadOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, string, error) {
var bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus string
err := tx.QueryRow(ctx, `SELECT binding.id::text,tenant.id::text,tenant.tenant_key,
tenant.default_user_group_id::text,group_record.group_key,binding.access_status
FROM gateway_oidc_tenant_bindings binding
JOIN gateway_tenants tenant ON tenant.id=binding.gateway_tenant_id
JOIN gateway_user_groups group_record ON group_record.id=tenant.default_user_group_id
WHERE binding.issuer=$1 AND binding.application_id=$2 AND binding.external_tenant_id=$3
AND tenant.status='active' AND tenant.deleted_at IS NULL AND group_record.status='active'
FOR UPDATE OF binding,tenant`,
input.Issuer, input.ApplicationID, input.TenantID,
).Scan(&bindingID, &gatewayTenantID, &tenantKey, &groupID, &groupKey, &accessStatus)
return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err
}
func createOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, error) {
var groupID, groupKey string
if err := tx.QueryRow(ctx, `SELECT id::text,group_key FROM gateway_user_groups
WHERE group_key='default' AND status='active'`).Scan(&groupID, &groupKey); err != nil {
return "", "", "", "", "", ErrOIDCTenantUnavailable
}
tenantKey := deriveOIDCMultiTenantKey(input.Issuer, input.ApplicationID, input.TenantID)
name := input.TenantName
if name == "" {
name = "认证中心租户 " + strings.ReplaceAll(input.TenantID, "-", "")[:8]
}
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit", "metadataStatus": input.TenantMetadataStatus})
var gatewayTenantID string
err := tx.QueryRow(ctx, `INSERT INTO gateway_tenants(
tenant_key,source,external_tenant_id,name,default_user_group_id,metadata,status,synced_at,source_updated_at
) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4::jsonb,'active',
CASE WHEN $5='synced' THEN now() ELSE NULL END,$6)
ON CONFLICT(tenant_key) DO UPDATE SET updated_at=gateway_tenants.updated_at
RETURNING id::text`, tenantKey, name, groupID, string(metadata), input.TenantMetadataStatus, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt)).Scan(&gatewayTenantID)
if err != nil {
return "", "", "", "", "", err
}
var bindingID string
err = tx.QueryRow(ctx, `INSERT INTO gateway_oidc_tenant_bindings(
issuer,application_id,external_tenant_id,gateway_tenant_id,access_status,metadata_status,
display_name,slug,metadata_version,metadata_etag,metadata_updated_at,last_sync_at,next_sync_at
) VALUES($1,$2,$3,$4::uuid,'active',$5,NULLIF($6,''),NULLIF($7,''),NULLIF($8,''),NULLIF($9,''),
$10,
CASE WHEN $5='synced' THEN now() ELSE NULL END,
CASE WHEN $5='synced' THEN now()+interval '15 minutes' ELSE now()+interval '30 seconds' END)
ON CONFLICT(issuer,application_id,external_tenant_id) DO UPDATE SET updated_at=gateway_oidc_tenant_bindings.updated_at
RETURNING id::text`,
input.Issuer, input.ApplicationID, input.TenantID, gatewayTenantID, input.TenantMetadataStatus,
input.TenantName, input.TenantSlug, input.TenantMetadataVersion, input.TenantMetadataETag,
nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt),
).Scan(&bindingID)
return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err
}
func loadOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, subject string) (GatewayUser, string, error) {
var userBindingID, gatewayUserID string
if err := tx.QueryRow(ctx, `SELECT binding.id::text,binding.gateway_user_id::text
FROM gateway_oidc_user_bindings binding
JOIN gateway_users ON gateway_users.id=binding.gateway_user_id
WHERE binding.tenant_binding_id=$1::uuid AND binding.subject=$2
AND gateway_users.deleted_at IS NULL
FOR UPDATE OF binding,gateway_users`, bindingID, subject).Scan(&userBindingID, &gatewayUserID); err != nil {
return GatewayUser{}, "", err
}
user, err := scanUser(tx.QueryRow(ctx, `SELECT `+userColumns+`
FROM gateway_users WHERE id=$1::uuid`, gatewayUserID))
return user, userBindingID, err
}
func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, gatewayTenantID, tenantKey, groupID string, input ResolveOrProvisionOIDCUserInput) (GatewayUser, string, bool, error) {
userKey := deriveOIDCMultiTenantUserKey(input.Issuer, input.ApplicationID, input.TenantID, input.Subject)
username := input.Username
if username == "" {
username = "oidc-" + strings.TrimPrefix(userKey, "oidc2:")[:12]
}
rolesJSON, _ := json.Marshal(input.Roles)
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"})
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
user_key,source,external_user_id,username,gateway_tenant_id,tenant_id,tenant_key,
default_user_group_id,roles,auth_profile,metadata,status,last_login_at,synced_at,source_updated_at
) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4,$5,$6::uuid,$7::jsonb,'{}'::jsonb,$8::jsonb,
'active',now(),now(),now())
ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at
RETURNING `+userColumns,
userKey, username, gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata),
))
if err != nil {
return GatewayUser{}, "", false, err
}
var userBindingID string
tag, err := tx.Exec(ctx, `INSERT INTO gateway_oidc_user_bindings(tenant_binding_id,subject,gateway_user_id)
VALUES($1::uuid,$2,$3::uuid) ON CONFLICT(tenant_binding_id,subject) DO NOTHING`,
bindingID, input.Subject, user.ID)
if err != nil {
return GatewayUser{}, "", false, err
}
created := tag.RowsAffected() == 1
if err := tx.QueryRow(ctx, `SELECT id::text FROM gateway_oidc_user_bindings
WHERE tenant_binding_id=$1::uuid AND subject=$2`, bindingID, input.Subject).Scan(&userBindingID); err != nil {
return GatewayUser{}, "", false, err
}
if _, err := s.ensureWalletAccount(ctx, tx, user.ID, "resource"); err != nil {
return GatewayUser{}, "", false, err
}
return user, userBindingID, created, nil
}
func deriveOIDCMultiTenantKey(issuer, applicationID, tenantID string) string {
sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID))
return fmt.Sprintf("oidc2-tenant-%x", sum[:16])
}
func nullableOIDCTenantMetadataTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
func deriveOIDCMultiTenantUserKey(issuer, applicationID, tenantID, subject string) string {
sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID + "\x00" + subject))
return fmt.Sprintf("oidc2:%x", sum)
}
func multiTenantAuthUser(user GatewayUser, groupKey string, input ResolveOrProvisionOIDCUserInput, userBindingID string) *auth.User {
result := authUserFromOIDCProjection(user, groupKey)
result.ID = input.Subject
result.TenantName = input.TenantName
result.Issuer = input.Issuer
result.ApplicationID = input.ApplicationID
result.OIDCClientID = input.OIDCClientID
result.OIDCUserBindingID = userBindingID
return result
}
+34 -4
View File
@@ -15,7 +15,12 @@ type OIDCSession struct {
SessionTokenHash []byte
GatewayUserID string
GatewayTenantID string
OIDCUserBindingID string
ExternalUserID string
Issuer string
ApplicationID string
TenantID string
OIDCClientID string
UserStatus string
UserDeleted bool
TokenCiphertext []byte
@@ -34,6 +39,11 @@ type CreateOIDCSessionInput struct {
SessionTokenHash []byte
GatewayUserID string
GatewayTenantID string
OIDCUserBindingID string
OIDCClientID string
Issuer string
ApplicationID string
TenantID string
TokenCiphertext []byte
AccessTokenExpiresAt time.Time
LastSeenAt time.Time
@@ -46,12 +56,27 @@ func (s *Store) CreateOIDCSession(ctx context.Context, input CreateOIDCSessionIn
err := s.pool.QueryRow(ctx, `
INSERT INTO gateway_oidc_sessions (
session_token_hash, gateway_user_id, gateway_tenant_id, token_ciphertext,
access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at
access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at,
oidc_user_binding_id, oidc_client_id
)
VALUES ($1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8)
SELECT $1, u.id, $3::uuid, $4, $5, $6, $7, $8, NULLIF($9, '')::uuid, NULLIF($10, '')
FROM gateway_users u
LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = NULLIF($9, '')::uuid
LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id
WHERE u.id = $2::uuid
AND u.gateway_tenant_id = $3::uuid
AND ($9 = '' OR (
ub.gateway_user_id = u.id
AND tb.gateway_tenant_id = u.gateway_tenant_id
AND tb.issuer = $11
AND tb.application_id = $12
AND tb.external_tenant_id = $13
))
RETURNING id::text`,
input.SessionTokenHash, input.GatewayUserID, input.GatewayTenantID, input.TokenCiphertext,
input.AccessTokenExpiresAt, input.LastSeenAt, input.IdleExpiresAt, input.AbsoluteExpiresAt,
input.OIDCUserBindingID, input.OIDCClientID,
input.Issuer, input.ApplicationID, input.TenantID,
).Scan(&id)
if err != nil {
return OIDCSession{}, err
@@ -64,6 +89,8 @@ func (s *Store) FindOIDCSessionByHash(ctx context.Context, hash []byte) (OIDCSes
SELECT `+oidcSessionColumns+`
FROM gateway_oidc_sessions s
JOIN gateway_users u ON u.id = s.gateway_user_id
LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = s.oidc_user_binding_id
LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id
WHERE s.session_token_hash = $1`, hash))
if errors.Is(err, pgx.ErrNoRows) {
return OIDCSession{}, ErrOIDCSessionNotFound
@@ -138,7 +165,9 @@ WHERE idle_expires_at <= $1 OR absolute_expires_at <= $1`, now)
const oidcSessionColumns = `
s.id::text, s.session_token_hash, s.gateway_user_id::text, s.gateway_tenant_id::text,
COALESCE(u.external_user_id, ''), u.status, u.deleted_at IS NOT NULL,
COALESCE(s.oidc_user_binding_id::text, ''), COALESCE(ub.subject, u.external_user_id, ''),
COALESCE(tb.issuer, ''), COALESCE(tb.application_id, ''), COALESCE(tb.external_tenant_id, ''),
COALESCE(s.oidc_client_id, ''), u.status, u.deleted_at IS NOT NULL,
s.token_ciphertext, s.access_token_expires_at, s.last_seen_at, s.idle_expires_at,
s.absolute_expires_at, s.refresh_version, COALESCE(s.refresh_lock_id::text, ''),
COALESCE(s.refresh_lock_until, 'epoch'::timestamptz), s.created_at, s.updated_at`
@@ -147,7 +176,8 @@ func scanOIDCSession(row pgx.Row) (OIDCSession, error) {
var item OIDCSession
err := row.Scan(
&item.ID, &item.SessionTokenHash, &item.GatewayUserID, &item.GatewayTenantID,
&item.ExternalUserID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext,
&item.OIDCUserBindingID, &item.ExternalUserID, &item.Issuer, &item.ApplicationID,
&item.TenantID, &item.OIDCClientID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext,
&item.AccessTokenExpiresAt, &item.LastSeenAt, &item.IdleExpiresAt, &item.AbsoluteExpiresAt,
&item.RefreshVersion, &item.RefreshLockID, &item.RefreshLockUntil, &item.CreatedAt, &item.UpdatedAt,
)
@@ -0,0 +1,176 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type OIDCTenantBindingSyncTarget struct {
ID string
ExternalTenantID string
ETag string
FailureCount int
}
type OIDCTenantBindingContext struct {
ID string
AccessStatus string
MetadataStatus string
DisplayName string
Slug string
Version string
ETag string
MetadataUpdatedAt time.Time
NextSyncAt time.Time
}
func (s *Store) DueOIDCTenantBindingSyncs(ctx context.Context, issuer, applicationID string, limit int) ([]OIDCTenantBindingSyncTarget, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.pool.Query(ctx, `SELECT id::text,external_tenant_id,COALESCE(metadata_etag,''),sync_failure_count
FROM gateway_oidc_tenant_bindings
WHERE issuer=$1 AND application_id=$2 AND access_status='active' AND next_sync_at <= now()
ORDER BY next_sync_at,id
LIMIT $3`, strings.TrimRight(strings.TrimSpace(issuer), "/"), strings.TrimSpace(applicationID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]OIDCTenantBindingSyncTarget, 0)
for rows.Next() {
var item OIDCTenantBindingSyncTarget
if err := rows.Scan(&item.ID, &item.ExternalTenantID, &item.ETag, &item.FailureCount); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) OIDCTenantBindingContext(ctx context.Context, issuer, applicationID, tenantID string) (OIDCTenantBindingContext, error) {
var item OIDCTenantBindingContext
err := s.pool.QueryRow(ctx, `SELECT id::text,access_status,metadata_status,COALESCE(display_name,''),
COALESCE(slug,''),COALESCE(metadata_version,''),COALESCE(metadata_etag,''),
COALESCE(metadata_updated_at,'epoch'::timestamptz),next_sync_at
FROM gateway_oidc_tenant_bindings
WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`,
strings.TrimRight(strings.TrimSpace(issuer), "/"), strings.TrimSpace(applicationID), strings.TrimSpace(tenantID),
).Scan(&item.ID, &item.AccessStatus, &item.MetadataStatus, &item.DisplayName, &item.Slug,
&item.Version, &item.ETag, &item.MetadataUpdatedAt, &item.NextSyncAt)
if errors.Is(err, pgx.ErrNoRows) {
return OIDCTenantBindingContext{}, ErrOIDCTenantUnavailable
}
return item, err
}
func (s *Store) ApplyOIDCTenantBindingSync(ctx context.Context, bindingID string, tenant identity.TenantContext, unchanged bool, now time.Time) error {
if uuid.Validate(bindingID) != nil {
return errors.New("OIDC tenant binding id is invalid")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if unchanged {
tag, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
SET last_sync_at=$2::timestamptz,next_sync_at=$2::timestamptz+interval '15 minutes',sync_failure_count=0,
last_error_category=NULL,updated_at=now()
WHERE id=$1::uuid AND access_status='active'`, bindingID, now)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrOIDCTenantUnavailable
}
return tx.Commit(ctx)
}
if !tenant.Active() || strings.TrimSpace(tenant.DisplayName) == "" || strings.TrimSpace(tenant.Slug) == "" {
return errors.New("OIDC tenant context is inactive or incomplete")
}
var gatewayTenantID string
err = tx.QueryRow(ctx, `UPDATE gateway_oidc_tenant_bindings
SET metadata_status='synced',display_name=$2,slug=$3,metadata_version=$4,metadata_etag=NULLIF($5,''),
metadata_updated_at=$6,last_sync_at=$7::timestamptz,
next_sync_at=$7::timestamptz+interval '15 minutes',
sync_failure_count=0,last_error_category=NULL,updated_at=now()
WHERE id=$1::uuid AND access_status='active'
RETURNING gateway_tenant_id::text`,
bindingID, tenant.DisplayName, tenant.Slug, tenant.Version, tenant.ETag, tenant.UpdatedAt, now,
).Scan(&gatewayTenantID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrOIDCTenantUnavailable
}
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE gateway_tenants
SET name=$2,synced_at=$3,source_updated_at=$4,updated_at=now()
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL`,
gatewayTenantID, tenant.DisplayName, now, tenant.UpdatedAt,
); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) RejectOIDCTenantBinding(ctx context.Context, bindingID, category string, now time.Time) error {
if uuid.Validate(bindingID) != nil {
return errors.New("OIDC tenant binding id is invalid")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
SET access_status='disabled',metadata_status='rejected',last_sync_at=$2::timestamptz,
next_sync_at=$2::timestamptz+interval '15 minutes',
last_error_category=$3,updated_at=now()
WHERE id=$1::uuid`, bindingID, now, limitOIDCTenantContextCategory(category))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrOIDCTenantUnavailable
}
if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions session
USING gateway_oidc_user_bindings user_binding
WHERE session.oidc_user_binding_id=user_binding.id AND user_binding.tenant_binding_id=$1::uuid`,
bindingID,
); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) FailOIDCTenantBindingSync(ctx context.Context, bindingID, category string, next time.Time) error {
if uuid.Validate(bindingID) != nil {
return errors.New("OIDC tenant binding id is invalid")
}
tag, err := s.pool.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
SET sync_failure_count=sync_failure_count+1,next_sync_at=$2,last_error_category=$3,updated_at=now()
WHERE id=$1::uuid AND access_status='active'`, bindingID, next, limitOIDCTenantContextCategory(category))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrOIDCTenantUnavailable
}
return nil
}
func limitOIDCTenantContextCategory(value string) string {
value = strings.TrimSpace(value)
if len(value) > 64 {
return value[:64]
}
return value
}
+29 -9
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/jackc/pgx/v5"
@@ -20,15 +21,24 @@ var (
)
type ResolveOrProvisionOIDCUserInput struct {
Issuer string
Subject string
Username string
Roles []string
TenantID string
GatewayTenantKey string
ProvisioningEnabled bool
RequestIP string
UserAgent string
Issuer string
ApplicationID string
Subject string
Username string
Roles []string
TenantID string
TenantMode string
TenantName string
TenantSlug string
TenantMetadataStatus string
TenantMetadataVersion string
TenantMetadataETag string
TenantMetadataUpdatedAt time.Time
OIDCClientID string
GatewayTenantKey string
ProvisioningEnabled bool
RequestIP string
UserAgent string
}
type ResolveOrProvisionOIDCUserResult struct {
@@ -48,6 +58,9 @@ type oidcUserProjection struct {
func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
input = normalizeOIDCUserInput(input)
if input.TenantMode == "multi_tenant" {
return s.resolveOrProvisionOIDCMultiTenantUser(ctx, input)
}
if input.Issuer == "" || input.Subject == "" || input.TenantID == "" {
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input")
}
@@ -296,6 +309,13 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
input.Subject = strings.TrimSpace(input.Subject)
input.Username = strings.TrimSpace(input.Username)
input.TenantID = strings.TrimSpace(input.TenantID)
input.ApplicationID = strings.TrimSpace(input.ApplicationID)
input.TenantMode = strings.TrimSpace(input.TenantMode)
input.TenantName = strings.TrimSpace(input.TenantName)
input.TenantSlug = strings.TrimSpace(input.TenantSlug)
input.TenantMetadataStatus = strings.TrimSpace(input.TenantMetadataStatus)
input.TenantMetadataVersion = strings.TrimSpace(input.TenantMetadataVersion)
input.TenantMetadataETag = strings.TrimSpace(input.TenantMetadataETag)
input.GatewayTenantKey = strings.TrimSpace(input.GatewayTenantKey)
input.RequestIP = strings.TrimSpace(input.RequestIP)
input.UserAgent = strings.TrimSpace(input.UserAgent)
@@ -11,9 +11,147 @@ import (
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC multi-tenant PostgreSQL integration tests")
}
ctx := context.Background()
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
issuer := "https://auth.test.example/issuer/shared"
applicationID, tenantA, tenantB, subject := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
return ResolveOrProvisionOIDCUserInput{
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant",
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(),
OIDCClientID: "gateway-browser", ProvisioningEnabled: true,
}
}
const concurrentLogins = 8
results := make([]ResolveOrProvisionOIDCUserResult, concurrentLogins)
errs := make([]error, concurrentLogins)
var wait sync.WaitGroup
for index := range concurrentLogins {
wait.Add(1)
go func(index int) {
defer wait.Done()
results[index], errs[index] = db.ResolveOrProvisionOIDCUser(ctx, input(tenantA, "Tenant A", "tenant-a"))
}(index)
}
wait.Wait()
var userA *auth.User
created := 0
for index, result := range results {
if errs[index] != nil || result.User == nil {
t.Fatalf("tenant A login %d result=%#v error=%v", index, result, errs[index])
}
if userA == nil {
userA = result.User
}
if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID {
t.Fatalf("concurrent tenant A projection diverged: first=%#v current=%#v", userA, result.User)
}
if result.Created {
created++
}
}
if created != 1 {
t.Fatalf("tenant A created projections=%d", created)
}
resultB, err := db.ResolveOrProvisionOIDCUser(ctx, input(tenantB, "Tenant B", "tenant-b"))
if err != nil || resultB.User == nil {
t.Fatalf("tenant B projection=%#v error=%v", resultB, err)
}
userB := resultB.User
if userA.GatewayUserID == userB.GatewayUserID || userA.GatewayTenantID == userB.GatewayTenantID ||
userA.ID != userB.ID {
t.Fatalf("same subject was not isolated by tenant: A=%#v B=%#v", userA, userB)
}
var tenants, users, wallets, audits int
if err := db.pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM gateway_oidc_tenant_bindings WHERE application_id=$1 AND external_tenant_id IN ($2,$3)),
(SELECT count(*) FROM gateway_oidc_user_bindings binding
JOIN gateway_oidc_tenant_bindings tenant ON tenant.id=binding.tenant_binding_id
WHERE tenant.application_id=$1 AND binding.subject=$4),
(SELECT count(*) FROM gateway_wallet_accounts WHERE gateway_user_id IN ($5::uuid,$6::uuid) AND currency='resource'),
(SELECT count(*) FROM gateway_audit_logs WHERE action='identity.oidc_user.provisioned'
AND target_gateway_user_id IN ($5::uuid,$6::uuid))`,
applicationID, tenantA, tenantB, subject, userA.GatewayUserID, userB.GatewayUserID,
).Scan(&tenants, &users, &wallets, &audits); err != nil {
t.Fatal(err)
}
if tenants != 2 || users != 2 || wallets != 2 || audits != 2 {
t.Fatalf("tenants=%d users=%d wallets=%d audits=%d", tenants, users, wallets, audits)
}
if _, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "Tenant A only"}, userA); err != nil {
t.Fatal(err)
}
keysB, err := db.ListAPIKeys(ctx, userB)
if err != nil || len(keysB) != 0 {
t.Fatalf("tenant B observed tenant A API keys: keys=%#v error=%v", keysB, err)
}
if _, err := db.CreateTask(ctx, CreateTaskInput{
Kind: "multi-tenant-isolation", Model: "local-fixture", RunMode: "async", Async: true,
Request: map[string]any{"prompt": "tenant-a"},
}, userA); err != nil {
t.Fatal(err)
}
tasksB, err := db.ListTasks(ctx, userB, TaskListFilter{})
if err != nil || len(tasksB.Items) != 0 {
t.Fatalf("tenant B observed tenant A tasks: tasks=%#v error=%v", tasksB.Items, err)
}
bindingA, err := db.OIDCTenantBindingContext(ctx, issuer, applicationID, tenantA)
if err != nil {
t.Fatal(err)
}
if err := db.RejectOIDCTenantBinding(ctx, bindingA.ID, "tenant_application_revoked", time.Now().UTC()); err != nil {
t.Fatal(err)
}
pending := input(tenantA, "", "")
pending.TenantMetadataStatus = "metadata_pending"
if _, err := db.ResolveOrProvisionOIDCUser(ctx, pending); !errorsIs(err, ErrOIDCTenantUnavailable) {
t.Fatalf("disabled binding accepted pending context: %v", err)
}
reactivated, err := db.ResolveOrProvisionOIDCUser(ctx, input(tenantA, "Tenant A restored", "tenant-a"))
if err != nil {
t.Fatal(err)
}
if reactivated.User.GatewayTenantID != userA.GatewayTenantID || reactivated.User.GatewayUserID != userA.GatewayUserID {
t.Fatalf("reassignment did not reuse local projection: before=%#v after=%#v", userA, reactivated.User)
}
if err := db.ApplyOIDCTenantBindingSync(ctx, bindingA.ID, identity.TenantContext{
ApplicationID: applicationID, TenantID: tenantA, DisplayName: "Tenant A renamed", Slug: "tenant-a",
TenantStatus: "active", TenantApplicationStatus: "active", Version: "v2", ETag: `"v2"`,
UpdatedAt: time.Unix(1_780_000_100, 0).UTC(),
}, false, time.Now().UTC()); err != nil {
t.Fatal(err)
}
synced, err := db.OIDCTenantBindingContext(ctx, issuer, applicationID, tenantA)
if err != nil || synced.DisplayName != "Tenant A renamed" || synced.Version != "v2" {
t.Fatalf("synced tenant context=%#v error=%v", synced, err)
}
}
func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
+58 -12
View File
@@ -16,6 +16,8 @@ type ApplySessionRevokedInput struct {
JTI string
TransactionID string
SubjectIssuer string
ApplicationID string
SubjectType string
TenantID string
Subject string
EventTimestamp time.Time
@@ -36,6 +38,9 @@ type SecurityEventEvaluation struct {
}
func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevokedInput) (ApplySecurityEventResult, error) {
if input.SubjectType == "" {
input.SubjectType = "principal"
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return ApplySecurityEventResult{}, err
@@ -44,13 +49,15 @@ func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevok
subjectHash := shortSecurityEventHash(input.Subject)
tag, err := tx.Exec(ctx, `
INSERT INTO gateway_security_event_receipts (
issuer, jti, audience, event_type, transaction_id, tenant_id, subject_hash, event_timestamp
issuer, jti, audience, event_type, transaction_id, tenant_id, subject_hash, event_timestamp,
application_id, subject_type
)
VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8)
VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8, NULLIF($9, ''), $10)
ON CONFLICT (issuer, jti) DO NOTHING`,
input.Issuer, input.JTI, input.Audience,
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
input.TransactionID, input.TenantID, subjectHash, input.EventTimestamp,
input.ApplicationID, input.SubjectType,
)
if err != nil {
return ApplySecurityEventResult{}, err
@@ -64,25 +71,56 @@ ON CONFLICT (issuer, jti) DO NOTHING`,
var revokedAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO gateway_oidc_revocation_watermarks (issuer, tenant_id, subject, revoked_at, source_jti)
VALUES ($1, $2, $3, $4, $5::uuid)
ON CONFLICT (issuer, tenant_id, subject) DO UPDATE
INSERT INTO gateway_oidc_revocation_watermarks (
issuer, application_id, tenant_id, subject_type, subject, revoked_at, source_jti
)
VALUES ($1, $2, $3, $4, $5, $6, $7::uuid)
ON CONFLICT (issuer, application_id, tenant_id, subject_type, subject) DO UPDATE
SET revoked_at = EXCLUDED.revoked_at, source_jti = EXCLUDED.source_jti, updated_at = now()
WHERE EXCLUDED.revoked_at > gateway_oidc_revocation_watermarks.revoked_at
RETURNING revoked_at`, input.SubjectIssuer, input.TenantID, input.Subject, input.EventTimestamp, input.JTI).Scan(&revokedAt)
RETURNING revoked_at`, input.SubjectIssuer, input.ApplicationID, input.TenantID, input.SubjectType,
input.Subject, input.EventTimestamp, input.JTI).Scan(&revokedAt)
watermarkMoved := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return ApplySecurityEventResult{}, err
}
var sessionsDeleted int64
if watermarkMoved {
tag, err = tx.Exec(ctx, `
switch {
case input.ApplicationID != "" && input.SubjectType == "principal":
tag, err = tx.Exec(ctx, `
DELETE FROM gateway_oidc_sessions session
USING gateway_oidc_user_bindings user_binding,gateway_oidc_tenant_bindings tenant_binding
WHERE session.oidc_user_binding_id=user_binding.id
AND user_binding.tenant_binding_id=tenant_binding.id
AND tenant_binding.issuer=$1
AND tenant_binding.application_id=$2
AND tenant_binding.external_tenant_id=$3
AND user_binding.subject=$4`, input.SubjectIssuer, input.ApplicationID, input.TenantID, input.Subject)
case input.ApplicationID != "" && input.SubjectType == "tenant":
tag, err = tx.Exec(ctx, `
DELETE FROM gateway_oidc_sessions session
USING gateway_oidc_user_bindings user_binding,gateway_oidc_tenant_bindings tenant_binding
WHERE session.oidc_user_binding_id=user_binding.id
AND user_binding.tenant_binding_id=tenant_binding.id
AND tenant_binding.issuer=$1
AND tenant_binding.application_id=$2
AND tenant_binding.external_tenant_id=$3`, input.SubjectIssuer, input.ApplicationID, input.TenantID)
if err == nil {
_, err = tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
SET access_status='disabled',updated_at=now()
WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`,
input.SubjectIssuer, input.ApplicationID, input.TenantID)
}
default:
tag, err = tx.Exec(ctx, `
DELETE FROM gateway_oidc_sessions session
USING gateway_users gateway_user
WHERE session.gateway_user_id = gateway_user.id
AND gateway_user.source = 'oidc'
AND gateway_user.external_user_id = $1
AND gateway_user.tenant_id = $2`, input.Subject, input.TenantID)
}
if err != nil {
return ApplySecurityEventResult{}, err
}
@@ -95,7 +133,7 @@ WHERE session.gateway_user_id = gateway_user.id
AfterState: map[string]any{"watermarkMoved": watermarkMoved, "sessionsDeleted": sessionsDeleted},
Metadata: map[string]any{
"issuer": input.SubjectIssuer, "transmitterIssuer": input.Issuer, "tenantId": input.TenantID, "jtiHash": jtiHash,
"initiatingEntity": input.InitiatingEntity,
"applicationId": input.ApplicationID, "subjectType": input.SubjectType, "initiatingEntity": input.InitiatingEntity,
},
})
if err != nil {
@@ -314,7 +352,7 @@ WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback'
return err
}
func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, tenantID, subject string, issuedAt, now time.Time, staleAfter time.Duration) (SecurityEventEvaluation, error) {
func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, applicationID, tenantID, subject string, issuedAt, now time.Time, staleAfter time.Duration) (SecurityEventEvaluation, error) {
var mode, streamStatus string
var lastVerification, bootstrapUntil *time.Time
var createdAt time.Time
@@ -322,9 +360,17 @@ func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer
err := s.pool.QueryRow(ctx, `
SELECT state.mode,state.stream_status,state.last_verification_at,state.bootstrap_until,state.created_at,watermark.revoked_at
FROM gateway_security_event_stream_state state
LEFT JOIN gateway_oidc_revocation_watermarks watermark
ON watermark.issuer=$3 AND watermark.tenant_id=$4 AND watermark.subject=$5
WHERE state.issuer=$1 AND state.audience=$2`, transmitterIssuer, audience, subjectIssuer, tenantID, subject).Scan(
LEFT JOIN LATERAL (
SELECT max(revoked_at) AS revoked_at
FROM gateway_oidc_revocation_watermarks
WHERE issuer=$3 AND application_id=$4 AND tenant_id=$5
AND (
(subject_type='principal' AND subject=$6)
OR (subject_type='tenant' AND subject=$5)
)
) watermark ON true
WHERE state.issuer=$1 AND state.audience=$2`,
transmitterIssuer, audience, subjectIssuer, applicationID, tenantID, subject).Scan(
&mode, &streamStatus, &lastVerification, &bootstrapUntil, &createdAt, &revokedAt,
)
if err != nil {
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -55,7 +56,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
if err := db.AdvanceSecurityEventStreamState(ctx, issuer, audience, now, 180*time.Second); err != nil {
t.Fatalf("advance fresh stream state: %v", err)
}
evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "bootstrap" {
t.Fatalf("bootstrap evaluation=%#v error=%v", evaluation, err)
}
@@ -77,12 +78,12 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
}
confirmAt("first-verification-state", now)
confirmAt("second-verification-state", now.Add(time.Minute))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(time.Minute), 180*time.Second)
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(time.Minute), 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("bootstrap overlap evaluation=%#v error=%v", evaluation, err)
}
confirmAt("post-bootstrap-verification-state", now.Add(361*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(361*time.Second), 180*time.Second)
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(361*time.Second), 180*time.Second)
if err != nil || evaluation.RequireIntrospection || evaluation.Mode != "push_healthy" {
t.Fatalf("healthy evaluation=%#v error=%v", evaluation, err)
}
@@ -97,7 +98,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
}
confirmAt("paused-verification-one", now.Add(363*time.Second))
confirmAt("paused-verification-two", now.Add(364*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(364*time.Second), 180*time.Second)
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(364*time.Second), 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("stream update fallback=%#v error=%v", evaluation, err)
}
@@ -114,22 +115,22 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
if err != nil || !duplicate.Duplicate {
t.Fatalf("duplicate result=%#v error=%v", duplicate, err)
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, revokedAt, now, 180*time.Second)
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, revokedAt, now, 180*time.Second)
if !evaluation.Revoked {
t.Fatal("token at watermark was accepted")
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", tenantID, subject, revokedAt, now, 180*time.Second)
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", "", tenantID, subject, revokedAt, now, 180*time.Second)
if evaluation.Revoked {
t.Fatal("watermark crossed OIDC issuer boundary")
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second)
if evaluation.Revoked {
t.Fatal("token after watermark was rejected")
}
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET last_verification_at=$3
WHERE issuer=$1 AND audience=$2`, issuer, audience, now.Add(-181*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("fallback evaluation=%#v error=%v", evaluation, err)
}
@@ -183,3 +184,136 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
t.Fatalf("rebind discarded security history receipts=%d watermarks=%d", retainedReceipts, retainedWatermarks)
}
}
func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run application-scoped security event integration tests")
}
ctx := context.Background()
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
transmitterIssuer := "https://auth.test.example/ssf/" + uuid.NewString()
subjectIssuer := "https://auth.test.example/issuer/shared"
applicationID, tenantA, tenantB, subject := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
projection := func(tenantID string) *auth.User {
result, err := db.ResolveOrProvisionOIDCUser(ctx, ResolveOrProvisionOIDCUserInput{
Issuer: subjectIssuer, ApplicationID: applicationID, Subject: subject, Username: "shared-user",
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant",
TenantName: "Tenant " + tenantID[:8], TenantSlug: "tenant-" + tenantID[:8],
TenantMetadataStatus: "synced", TenantMetadataVersion: "1",
TenantMetadataUpdatedAt: time.Now().UTC(), OIDCClientID: "gateway-browser", ProvisioningEnabled: true,
})
if err != nil {
t.Fatal(err)
}
return result.User
}
userA, userB := projection(tenantA), projection(tenantB)
if _, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "must survive OIDC revocation"}, userA); err != nil {
t.Fatal(err)
}
createSession := func(user *auth.User, marker byte) {
now := time.Now().UTC()
sessionHash := sha256.Sum256([]byte(uuid.NewString()))
if _, err := db.CreateOIDCSession(ctx, CreateOIDCSessionInput{
SessionTokenHash: sessionHash[:], GatewayUserID: user.GatewayUserID,
GatewayTenantID: user.GatewayTenantID, OIDCUserBindingID: user.OIDCUserBindingID,
OIDCClientID: "gateway-browser", Issuer: subjectIssuer, ApplicationID: applicationID,
TenantID: user.TenantID, TokenCiphertext: []byte{marker},
AccessTokenExpiresAt: now.Add(time.Hour), LastSeenAt: now,
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour),
}); err != nil {
t.Fatal(err)
}
}
createSession(userA, 0xa1)
createSession(userB, 0xb1)
revokedAt := time.Now().UTC().Truncate(time.Second)
principalEvent := ApplySessionRevokedInput{
Issuer: transmitterIssuer, Audience: "urn:easyai:ssf:receiver:" + applicationID,
JTI: uuid.NewString(), TransactionID: uuid.NewString(), SubjectIssuer: subjectIssuer,
ApplicationID: applicationID, SubjectType: "principal", TenantID: tenantA, Subject: subject,
EventTimestamp: revokedAt, InitiatingEntity: "admin",
}
result, err := db.ApplySessionRevoked(ctx, principalEvent)
if err != nil || result.SessionsDeleted != 1 || !result.WatermarkMoved {
t.Fatalf("principal result=%#v error=%v", result, err)
}
var tenantASessions, tenantBSessions int
countSessions := func() {
t.Helper()
if err := db.pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$1::uuid),
(SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$2::uuid)`,
userA.GatewayTenantID, userB.GatewayTenantID,
).Scan(&tenantASessions, &tenantBSessions); err != nil {
t.Fatal(err)
}
}
countSessions()
if tenantASessions != 0 || tenantBSessions != 1 {
t.Fatalf("principal revocation sessions A=%d B=%d", tenantASessions, tenantBSessions)
}
older := principalEvent
older.JTI = uuid.NewString()
older.EventTimestamp = revokedAt.Add(-time.Minute)
result, err = db.ApplySessionRevoked(ctx, older)
if err != nil || result.WatermarkMoved || result.SessionsDeleted != 0 {
t.Fatalf("older result=%#v error=%v", result, err)
}
createSession(userA, 0xa2)
tenantEvent := principalEvent
tenantEvent.JTI, tenantEvent.TransactionID = uuid.NewString(), uuid.NewString()
tenantEvent.SubjectType, tenantEvent.Subject = "tenant", tenantA
tenantEvent.EventTimestamp = revokedAt.Add(time.Minute)
result, err = db.ApplySessionRevoked(ctx, tenantEvent)
if err != nil || result.SessionsDeleted != 1 || !result.WatermarkMoved {
t.Fatalf("tenant result=%#v error=%v", result, err)
}
countSessions()
if tenantASessions != 0 || tenantBSessions != 1 {
t.Fatalf("tenant revocation sessions A=%d B=%d", tenantASessions, tenantBSessions)
}
keysA, err := db.ListAPIKeys(ctx, userA)
if err != nil || len(keysA) != 1 {
t.Fatalf("tenant revocation changed API keys: keys=%#v error=%v", keysA, err)
}
var accessStatus string
if err := db.pool.QueryRow(ctx, `SELECT access_status FROM gateway_oidc_tenant_bindings
WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`,
subjectIssuer, applicationID, tenantA,
).Scan(&accessStatus); err != nil || accessStatus != "disabled" {
t.Fatalf("tenant A access status=%q error=%v", accessStatus, err)
}
streamID := uuid.NewString()
audience := tenantEvent.Audience
if err := db.EnsureSecurityEventStreamState(ctx, transmitterIssuer, audience, streamID); err != nil {
t.Fatal(err)
}
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state
SET stream_status='enabled',mode='push_healthy',last_verification_at=$3,bootstrap_until=$3
WHERE issuer=$1 AND audience=$2`, transmitterIssuer, audience, revokedAt.Add(2*time.Minute))
evaluationA, err := db.EvaluateOIDCSecurityEvent(
ctx, transmitterIssuer, audience, subjectIssuer, applicationID, tenantA, subject,
tenantEvent.EventTimestamp, revokedAt.Add(2*time.Minute), 5*time.Minute,
)
if err != nil || !evaluationA.Revoked {
t.Fatalf("tenant A evaluation=%#v error=%v", evaluationA, err)
}
evaluationB, err := db.EvaluateOIDCSecurityEvent(
ctx, transmitterIssuer, audience, subjectIssuer, applicationID, tenantB, subject,
tenantEvent.EventTimestamp, revokedAt.Add(2*time.Minute), 5*time.Minute,
)
if err != nil || evaluationB.Revoked {
t.Fatalf("tenant B evaluation=%#v error=%v", evaluationB, err)
}
}