feat(identity): 增加统一认证配置版本
新增统一认证 Revision 状态机、单 Active 数据库约束、SecretStore 引用字段、Break-glass 与本地租户门禁,并在关键身份变化或禁用时清理旧 BFF Session。\n\n同时实现标准应用接入 Manifest v1 消费端,接入码只进入请求 Body,Exchange Token 只进入 Authorization Header,禁用重定向并限制响应大小。\n\n验证:go test ./...;go vet ./...
This commit is contained in:
parent
2a73e18123
commit
c2ce42fead
@ -39,3 +39,27 @@ func TestSecurityEventVerificationStateRepairMigrationExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityConfigurationRevisionMigrationDefinesSecretReferencesAndSingleActiveRevision(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0067_identity_configuration_revisions.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(payload)
|
||||
for _, required := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions",
|
||||
"machine_credential_ref text",
|
||||
"session_encryption_key_ref text",
|
||||
"WHERE state = 'active'",
|
||||
"CHECK (state IN ('draft','validated','active','superseded','failed'))",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity configuration migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"machine_secret", "client_secret", "exchange_token text", "onboarding_code text"} {
|
||||
if strings.Contains(strings.ToLower(content), forbidden) {
|
||||
t.Fatalf("identity configuration migration stores forbidden secret field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
222
apps/api/internal/identity/configuration.go
Normal file
222
apps/api/internal/identity/configuration.go
Normal file
@ -0,0 +1,222 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
type RevisionState string
|
||||
|
||||
const (
|
||||
RevisionDraft RevisionState = "draft"
|
||||
RevisionValidated RevisionState = "validated"
|
||||
RevisionActive RevisionState = "active"
|
||||
RevisionSuperseded RevisionState = "superseded"
|
||||
RevisionFailed RevisionState = "failed"
|
||||
)
|
||||
|
||||
type Revision struct {
|
||||
ID string `json:"id"`
|
||||
State RevisionState `json:"state"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
AuthCenterURL string `json:"authCenterUrl"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
BrowserClientID string `json:"browserClientId,omitempty"`
|
||||
MachineClientID string `json:"machineClientId,omitempty"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
RolePrefix string `json:"rolePrefix"`
|
||||
LocalTenantKey string `json:"localTenantKey"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
WebBaseURL string `json:"webBaseUrl"`
|
||||
JITEnabled bool `json:"jitEnabled"`
|
||||
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
|
||||
TokenIntrospection bool `json:"tokenIntrospection"`
|
||||
SessionRevocation bool `json:"sessionRevocation"`
|
||||
MachineCredentialRef string `json:"-"`
|
||||
SessionEncryptionKeyRef string `json:"-"`
|
||||
SessionIdleSeconds int `json:"sessionIdleSeconds"`
|
||||
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
|
||||
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
|
||||
Version int64 `json:"version"`
|
||||
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
|
||||
LastTraceID string `json:"lastTraceId,omitempty"`
|
||||
LastAuditID string `json:"lastAuditId,omitempty"`
|
||||
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
|
||||
ActivatedAt *time.Time `json:"activatedAt,omitempty"`
|
||||
SupersededAt *time.Time `json:"supersededAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PairingInput struct {
|
||||
AuthCenterURL string `json:"authCenterUrl"`
|
||||
OnboardingCode string `json:"onboardingCode"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
WebBaseURL string `json:"webBaseUrl"`
|
||||
LocalTenantKey string `json:"localTenantKey"`
|
||||
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
|
||||
}
|
||||
|
||||
type ConsumerMetadata struct {
|
||||
PublicBaseURL string `json:"public_base_url"`
|
||||
WebBaseURL string `json:"web_base_url"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
LogoutURIs []string `json:"logout_uris"`
|
||||
ReceiverEndpoint string `json:"receiver_endpoint,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestApplication struct {
|
||||
Manifest ManifestV1
|
||||
MachineCredentialRef string
|
||||
SessionEncryptionKeyRef string
|
||||
TraceID string
|
||||
AuditID string
|
||||
}
|
||||
|
||||
func NewDraft(input PairingInput) (Revision, error) {
|
||||
if _, err := input.ConsumerMetadata(false); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
authCenter, _ := exactBaseURL(input.AuthCenterURL)
|
||||
publicBase, _ := exactBaseURL(input.PublicBaseURL)
|
||||
webBase, _ := exactBaseURL(input.WebBaseURL)
|
||||
return Revision{
|
||||
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1,
|
||||
AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey),
|
||||
PublicBaseURL: publicBase, WebBaseURL: webBase, JITEnabled: true, LegacyJWTEnabled: input.LegacyJWTEnabled,
|
||||
Scopes: []string{}, Capabilities: []string{}, SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800,
|
||||
SessionRefreshSeconds: 60, Version: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ApplyManifest(revision Revision, input ManifestApplication) (Revision, error) {
|
||||
if revision.State != RevisionDraft {
|
||||
return Revision{}, ErrRevisionConflict
|
||||
}
|
||||
if err := input.Manifest.Validate(); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
capabilities := make(map[string]bool, len(input.Manifest.Capabilities))
|
||||
for _, capability := range input.Manifest.Capabilities {
|
||||
capabilities[capability] = true
|
||||
}
|
||||
if capabilities["machine_to_machine"] && strings.TrimSpace(input.MachineCredentialRef) == "" {
|
||||
return Revision{}, errors.New("machine credential reference is required")
|
||||
}
|
||||
if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" {
|
||||
return Revision{}, errors.New("session encryption key reference is required")
|
||||
}
|
||||
revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/")
|
||||
revision.TenantID = input.Manifest.TenantID
|
||||
revision.ApplicationID = input.Manifest.ApplicationID
|
||||
revision.Audience = input.Manifest.Audience
|
||||
revision.Scopes = append([]string(nil), input.Manifest.Scopes...)
|
||||
revision.Capabilities = append([]string(nil), input.Manifest.Capabilities...)
|
||||
if input.Manifest.Clients.BrowserLogin != nil {
|
||||
revision.BrowserClientID = input.Manifest.Clients.BrowserLogin.ClientID
|
||||
}
|
||||
if input.Manifest.Clients.MachineToMachine != nil {
|
||||
revision.MachineClientID = input.Manifest.Clients.MachineToMachine.ClientID
|
||||
}
|
||||
revision.TokenIntrospection = capabilities["token_introspection"]
|
||||
revision.SessionRevocation = capabilities["session_revocation"]
|
||||
revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef)
|
||||
revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef)
|
||||
revision.LastTraceID = strings.TrimSpace(input.TraceID)
|
||||
revision.LastAuditID = strings.TrimSpace(input.AuditID)
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func CanTransition(from, to RevisionState) bool {
|
||||
switch from {
|
||||
case RevisionDraft:
|
||||
return to == RevisionValidated || to == RevisionFailed
|
||||
case RevisionValidated:
|
||||
return to == RevisionActive || to == RevisionFailed
|
||||
case RevisionActive:
|
||||
return to == RevisionSuperseded
|
||||
case RevisionSuperseded:
|
||||
return to == RevisionValidated
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMetadata, error) {
|
||||
authCenter, err := exactBaseURL(input.AuthCenterURL)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("auth center URL is invalid")
|
||||
}
|
||||
_ = authCenter
|
||||
publicBase, err := exactBaseURL(input.PublicBaseURL)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("public base URL is invalid")
|
||||
}
|
||||
webBase, err := exactBaseURL(input.WebBaseURL)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("web base URL is invalid")
|
||||
}
|
||||
if strings.TrimSpace(input.LocalTenantKey) == "" {
|
||||
return ConsumerMetadata{}, errors.New("local tenant mapping is required")
|
||||
}
|
||||
metadata := ConsumerMetadata{
|
||||
PublicBaseURL: publicBase, WebBaseURL: webBase,
|
||||
RedirectURIs: []string{publicBase + "/api/v1/auth/oidc/callback"},
|
||||
LogoutURIs: []string{webBase + "/"},
|
||||
}
|
||||
if sessionRevocation {
|
||||
metadata.ReceiverEndpoint = publicBase + "/api/v1/security-events/ssf"
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func exactBaseURL(raw 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")
|
||||
}
|
||||
hostname := strings.ToLower(parsed.Hostname())
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return "", errors.New("base URL must not contain a path")
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "https" && !(scheme == "http" && isLoopbackHost(hostname)) {
|
||||
return "", errors.New("public URL must use HTTPS")
|
||||
}
|
||||
port := parsed.Port()
|
||||
if scheme == "https" && port == "443" || scheme == "http" && port == "80" {
|
||||
port = ""
|
||||
}
|
||||
host := hostname
|
||||
if strings.Contains(hostname, ":") {
|
||||
host = "[" + hostname + "]"
|
||||
}
|
||||
if port != "" {
|
||||
host = net.JoinHostPort(hostname, port)
|
||||
}
|
||||
return scheme + "://" + host, nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
89
apps/api/internal/identity/configuration_test.go
Normal file
89
apps/api/internal/identity/configuration_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRevisionStateTransitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
from, to RevisionState
|
||||
allowed bool
|
||||
}{
|
||||
{RevisionDraft, RevisionValidated, true},
|
||||
{RevisionDraft, RevisionFailed, true},
|
||||
{RevisionValidated, RevisionActive, true},
|
||||
{RevisionValidated, RevisionFailed, true},
|
||||
{RevisionActive, RevisionSuperseded, true},
|
||||
{RevisionSuperseded, RevisionValidated, true},
|
||||
{RevisionSuperseded, RevisionActive, false},
|
||||
{RevisionDraft, RevisionActive, false},
|
||||
{RevisionFailed, RevisionActive, false},
|
||||
{RevisionActive, RevisionValidated, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := CanTransition(test.from, test.to); got != test.allowed {
|
||||
t.Errorf("CanTransition(%q, %q)=%v, want %v", test.from, test.to, got, test.allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionNeverSerializesSecretValues(t *testing.T) {
|
||||
type secretFields interface {
|
||||
MachineSecret() string
|
||||
}
|
||||
var _ = any((*Revision)(nil))
|
||||
if _, exposesSecret := any((*Revision)(nil)).(secretFields); exposesSecret {
|
||||
t.Fatal("Revision unexpectedly exposes a machine secret value")
|
||||
}
|
||||
revision := Revision{MachineCredentialRef: "identity-machine-example", SessionEncryptionKeyRef: "identity-session-example"}
|
||||
if revision.MachineCredentialRef == "" || revision.SessionEncryptionKeyRef == "" {
|
||||
t.Fatal("Revision must retain SecretStore references")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePairingInputDerivesExactGatewayURIs(t *testing.T) {
|
||||
input := PairingInput{
|
||||
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
|
||||
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
|
||||
}
|
||||
metadata, err := input.ConsumerMetadata(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.RedirectURIs[0] != "https://api.example.com/api/v1/auth/oidc/callback" ||
|
||||
metadata.LogoutURIs[0] != "https://gateway.example.com/" ||
|
||||
metadata.ReceiverEndpoint != "https://api.example.com/api/v1/security-events/ssf" {
|
||||
t.Fatalf("unexpected derived metadata: %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) {
|
||||
for _, input := range []PairingInput{
|
||||
{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 {
|
||||
t.Fatalf("unsafe pairing input accepted: %#v", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testing.T) {
|
||||
draft, err := NewDraft(PairingInput{
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if draft.State != RevisionDraft || draft.SessionIdleSeconds != 1800 || draft.SessionAbsoluteSeconds != 28800 || draft.SessionRefreshSeconds != 60 {
|
||||
t.Fatalf("unexpected draft defaults: %#v", draft)
|
||||
}
|
||||
payload, _ := json.Marshal(draft)
|
||||
if strings.Contains(string(payload), "must-never-be-persisted") || strings.Contains(string(payload), "onboardingCode") {
|
||||
t.Fatalf("draft exposed onboarding code: %s", payload)
|
||||
}
|
||||
}
|
||||
300
apps/api/internal/identity/onboarding_client.go
Normal file
300
apps/api/internal/identity/onboarding_client.go
Normal file
@ -0,0 +1,300 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const maxOnboardingResponseBytes = 1 << 20
|
||||
|
||||
type ExchangeStatus string
|
||||
|
||||
const (
|
||||
ExchangeMetadataPending ExchangeStatus = "metadata_pending"
|
||||
ExchangePreparing ExchangeStatus = "preparing"
|
||||
ExchangeReady ExchangeStatus = "ready"
|
||||
ExchangeCredentialDelivered ExchangeStatus = "credential_delivered"
|
||||
ExchangeCompleted ExchangeStatus = "completed"
|
||||
ExchangeFailed ExchangeStatus = "failed"
|
||||
ExchangeExpired ExchangeStatus = "expired"
|
||||
)
|
||||
|
||||
type ClaimedExchange struct {
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
ExchangeToken string `json:"exchange_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
type Exchange struct {
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Status ExchangeStatus `json:"status"`
|
||||
Version int64 `json:"version"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
LastErrorCategory string `json:"last_error_category,omitempty"`
|
||||
AuditID string `json:"audit_id,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
type ManifestClients struct {
|
||||
BrowserLogin *ManifestClient `json:"browser_login,omitempty"`
|
||||
MachineToMachine *ManifestClient `json:"machine_to_machine,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestSecurityEvents struct {
|
||||
TransmitterIssuer string `json:"transmitter_issuer"`
|
||||
ConfigurationEndpoint string `json:"configuration_endpoint"`
|
||||
Audience string `json:"audience"`
|
||||
}
|
||||
|
||||
type ManifestV1 struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Issuer string `json:"issuer"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Clients ManifestClients `json:"clients"`
|
||||
SecurityEvents *ManifestSecurityEvents `json:"security_events,omitempty"`
|
||||
}
|
||||
|
||||
type MachineCredential struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
type CredentialDelivery struct {
|
||||
Manifest ManifestV1 `json:"manifest"`
|
||||
MachineCredential *MachineCredential `json:"machine_credential,omitempty"`
|
||||
Version int64 `json:"-"`
|
||||
}
|
||||
|
||||
func (manifest ManifestV1) Validate() error {
|
||||
if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer) != nil {
|
||||
return errors.New("application manifest identity metadata is invalid")
|
||||
}
|
||||
if _, err := uuid.Parse(manifest.TenantID); err != nil {
|
||||
return errors.New("application manifest tenant is invalid")
|
||||
}
|
||||
if _, err := uuid.Parse(manifest.ApplicationID); err != nil {
|
||||
return errors.New("application manifest application is invalid")
|
||||
}
|
||||
allowed := map[string]bool{"oidc_login": true, "api_access": true, "machine_to_machine": true, "token_introspection": true, "session_revocation": true}
|
||||
capabilities := make(map[string]bool, len(manifest.Capabilities))
|
||||
for _, capability := range manifest.Capabilities {
|
||||
if !allowed[capability] || capabilities[capability] {
|
||||
return errors.New("application manifest capabilities are invalid")
|
||||
}
|
||||
capabilities[capability] = true
|
||||
}
|
||||
if capabilities["session_revocation"] && !capabilities["token_introspection"] ||
|
||||
capabilities["token_introspection"] && !capabilities["machine_to_machine"] {
|
||||
return errors.New("application manifest capability dependencies are invalid")
|
||||
}
|
||||
if capabilities["oidc_login"] && (manifest.Clients.BrowserLogin == nil || strings.TrimSpace(manifest.Clients.BrowserLogin.ClientID) == "") {
|
||||
return errors.New("application manifest browser client is missing")
|
||||
}
|
||||
if capabilities["machine_to_machine"] && (manifest.Clients.MachineToMachine == nil || strings.TrimSpace(manifest.Clients.MachineToMachine.ClientID) == "") {
|
||||
return errors.New("application manifest machine client is missing")
|
||||
}
|
||||
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) == "" {
|
||||
return errors.New("application manifest security event metadata is invalid")
|
||||
}
|
||||
}
|
||||
seenScopes := make(map[string]bool, len(manifest.Scopes))
|
||||
for _, scope := range manifest.Scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" || seenScopes[scope] {
|
||||
return errors.New("application manifest scopes are invalid")
|
||||
}
|
||||
seenScopes[scope] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePublicIdentityURL(raw string) error {
|
||||
_, err := exactBaseURL(raw)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// Discovery endpoints may contain a path, but must retain the same URL
|
||||
// safety constraints as a base URL.
|
||||
parsed, parseErr := http.NewRequest(http.MethodGet, strings.TrimSpace(raw), nil)
|
||||
if parseErr != nil || parsed.URL.User != nil || parsed.URL.Host == "" || parsed.URL.RawQuery != "" || parsed.URL.Fragment != "" {
|
||||
return errors.New("identity URL is invalid")
|
||||
}
|
||||
scheme := strings.ToLower(parsed.URL.Scheme)
|
||||
if scheme == "https" || scheme == "http" && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("identity URL must use HTTPS")
|
||||
}
|
||||
|
||||
type OnboardingClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient, error) {
|
||||
normalized, err := exactBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return nil, errors.New("auth center URL is invalid")
|
||||
}
|
||||
if base == nil {
|
||||
base = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
client := *base
|
||||
if client.Timeout <= 0 {
|
||||
client.Timeout = 10 * time.Second
|
||||
}
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &OnboardingClient{baseURL: normalized, client: &client}, nil
|
||||
}
|
||||
|
||||
func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) {
|
||||
var output ClaimedExchange
|
||||
status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges", "", "", 0,
|
||||
map[string]string{"onboarding_code": strings.TrimSpace(code)}, &output)
|
||||
if err != nil || status != http.StatusCreated || output.Version < 1 || output.ExchangeID == "" || output.ExchangeToken == "" {
|
||||
return ClaimedExchange{}, onboardingProtocolError(err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (client *OnboardingClient) SubmitMetadata(ctx context.Context, claimed ClaimedExchange, metadata ConsumerMetadata, idempotencyKey string) (Exchange, error) {
|
||||
var output Exchange
|
||||
status, _, err := client.request(ctx, http.MethodPut, "/api/v1/onboarding-exchanges/"+claimed.ExchangeID+"/metadata",
|
||||
claimed.ExchangeToken, idempotencyKey, claimed.Version, metadata, &output)
|
||||
if err != nil || status != http.StatusAccepted || output.Version < 1 {
|
||||
return Exchange{}, onboardingProtocolError(err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (client *OnboardingClient) Get(ctx context.Context, exchangeID, exchangeToken string) (Exchange, error) {
|
||||
var output Exchange
|
||||
status, _, err := client.request(ctx, http.MethodGet, "/api/v1/onboarding-exchanges/"+exchangeID, exchangeToken, "", 0, nil, &output)
|
||||
if err != nil || status != http.StatusOK || output.Version < 1 {
|
||||
return Exchange{}, onboardingProtocolError(err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange Exchange, exchangeToken, idempotencyKey string) (CredentialDelivery, error) {
|
||||
var output CredentialDelivery
|
||||
status, etag, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchange.ExchangeID+"/credential-deliveries",
|
||||
exchangeToken, idempotencyKey, exchange.Version, nil, &output)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return CredentialDelivery{}, onboardingProtocolError(err)
|
||||
}
|
||||
if err := output.Manifest.Validate(); err != nil {
|
||||
return CredentialDelivery{}, err
|
||||
}
|
||||
output.Version, err = parseWeakETag(etag)
|
||||
if err != nil {
|
||||
return CredentialDelivery{}, errors.New("onboarding response ETag is invalid")
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (client *OnboardingClient) Complete(ctx context.Context, exchangeID, exchangeToken, idempotencyKey string, version int64) error {
|
||||
status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchangeID+"/completion",
|
||||
exchangeToken, idempotencyKey, version, nil, nil)
|
||||
if err != nil || status != http.StatusNoContent {
|
||||
return onboardingProtocolError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *OnboardingClient) request(ctx context.Context, method, path, token, idempotencyKey string, version int64, body, output any) (int, string, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
reader = bytes.NewReader(payload)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, reader)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
request.Header.Set("Idempotency-Key", idempotencyKey)
|
||||
}
|
||||
if version > 0 {
|
||||
request.Header.Set("If-Match", fmt.Sprintf(`W/"%d"`, version))
|
||||
}
|
||||
response, err := client.client.Do(request)
|
||||
if err != nil {
|
||||
return 0, "", errors.New("onboarding endpoint request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 300 && response.StatusCode < 400 {
|
||||
return response.StatusCode, "", errors.New("onboarding endpoint redirect is forbidden")
|
||||
}
|
||||
if output == nil || response.StatusCode == http.StatusNoContent {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
|
||||
return response.StatusCode, response.Header.Get("ETag"), nil
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, maxOnboardingResponseBytes+1))
|
||||
if err != nil || len(payload) > maxOnboardingResponseBytes {
|
||||
return response.StatusCode, "", errors.New("onboarding endpoint response is invalid")
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return response.StatusCode, "", errors.New("onboarding endpoint rejected the request")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(output); err != nil {
|
||||
return response.StatusCode, "", errors.New("onboarding endpoint response is invalid")
|
||||
}
|
||||
return response.StatusCode, response.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
func onboardingProtocolError(cause error) error {
|
||||
if cause == nil {
|
||||
return errors.New("onboarding endpoint returned an unexpected status")
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func parseWeakETag(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if !strings.HasPrefix(value, `W/"`) || !strings.HasSuffix(value, `"`) {
|
||||
return 0, errors.New("invalid ETag")
|
||||
}
|
||||
version, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(value, `W/"`), `"`), 10, 64)
|
||||
if err != nil || version < 1 {
|
||||
return 0, errors.New("invalid ETag")
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
97
apps/api/internal/identity/onboarding_client_test.go
Normal file
97
apps/api/internal/identity/onboarding_client_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOnboardingClientKeepsCodeAndExchangeTokenOutOfURLs(t *testing.T) {
|
||||
const code = "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH"
|
||||
const token = "ex1.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb.abcdefghijklmnopqrstuvwxyzABCDEFGH"
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.String(), code) || strings.Contains(r.URL.String(), token) {
|
||||
t.Fatal("onboarding credential leaked into URL")
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/onboarding-exchanges":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["onboarding_code"] != code || r.Header.Get("Authorization") != "" {
|
||||
t.Fatalf("unexpected claim request: body=%#v authorization=%q", body, r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","exchange_token":"` + token + `","expires_at":"2026-07-17T12:30:00Z","version":1}`))
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/onboarding-exchanges/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/metadata":
|
||||
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("If-Match") != `W/"1"` || len(r.Header.Get("Idempotency-Key")) < 16 {
|
||||
t.Fatalf("unexpected exchange headers: %#v", r.Header)
|
||||
}
|
||||
w.Header().Set("ETag", `W/"2"`)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","application_id":"cccccccc-cccc-cccc-cccc-cccccccccccc","status":"preparing","version":2,"expires_at":"2026-07-17T12:30:00Z"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewOnboardingClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := client.Claim(context.Background(), code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := ConsumerMetadata{PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", RedirectURIs: []string{"https://api.example.com/api/v1/auth/oidc/callback"}, LogoutURIs: []string{"https://gateway.example.com/"}}
|
||||
view, err := client.SubmitMetadata(context.Background(), claimed, metadata, "pairing-metadata-123456")
|
||||
if err != nil || view.Status != ExchangePreparing || view.Version != 2 {
|
||||
t.Fatalf("view=%#v err=%v", view, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *testing.T) {
|
||||
valid := ManifestV1{
|
||||
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared",
|
||||
TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"},
|
||||
Audience: "urn:easyai:resource:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", 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:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := valid
|
||||
invalid.Capabilities = []string{"session_revocation"}
|
||||
if err := invalid.Validate(); err == nil {
|
||||
t.Fatal("manifest with missing capability dependencies was accepted")
|
||||
}
|
||||
invalid = valid
|
||||
invalid.SchemaVersion = 2
|
||||
if err := invalid.Validate(); err == nil {
|
||||
t.Fatal("unsupported manifest schema was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingClientRejectsRedirects(t *testing.T) {
|
||||
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "should not be reached", http.StatusTeapot)
|
||||
}))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
client, err := NewOnboardingClient(redirect.URL, redirect.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.Claim(context.Background(), "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH"); err == nil {
|
||||
t.Fatal("redirecting onboarding endpoint was accepted")
|
||||
}
|
||||
}
|
||||
240
apps/api/internal/store/identity_configurations.go
Normal file
240
apps/api/internal/store/identity_configurations.go
Normal file
@ -0,0 +1,240 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const identityRevisionColumns = `
|
||||
id::text,state,schema_version,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(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`
|
||||
|
||||
func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revision identity.Revision) (identity.Revision, error) {
|
||||
scopes, _ := json.Marshal(revision.Scopes)
|
||||
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,
|
||||
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)
|
||||
RETURNING `+identityRevisionColumns,
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) IdentityConfigurationRevision(ctx context.Context, id string) (identity.Revision, error) {
|
||||
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
|
||||
FROM gateway_identity_configuration_revisions WHERE id=$1::uuid`, id))
|
||||
return revision, normalizeIdentityRevisionError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ActiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) {
|
||||
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
|
||||
FROM gateway_identity_configuration_revisions WHERE state='active'`))
|
||||
return revision, normalizeIdentityRevisionError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) {
|
||||
current, err := s.IdentityConfigurationRevision(ctx, id)
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
if current.Version != expectedVersion {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
updated, err := identity.ApplyManifest(current, applied)
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
scopes, _ := json.Marshal(updated.Scopes)
|
||||
capabilities, _ := json.Marshal(updated.Capabilities)
|
||||
revision, err := scanIdentityRevision(s.pool.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,
|
||||
machine_credential_ref=NULLIF($13,''),session_encryption_key_ref=NULLIF($14,''),last_trace_id=NULLIF($15,''),
|
||||
last_audit_id=NULLIF($16,''),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,
|
||||
updated.SessionRevocation, updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID,
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
return revision, err
|
||||
}
|
||||
|
||||
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')
|
||||
RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
return revision, err
|
||||
}
|
||||
|
||||
func (s *Store) MarkIdentityRevisionFailed(ctx context.Context, id string, expectedVersion int64, category, traceID, auditID string) (identity.Revision, error) {
|
||||
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
|
||||
UPDATE gateway_identity_configuration_revisions SET state='failed',last_error_category=NULLIF($3,''),
|
||||
last_trace_id=NULLIF($4,''),last_audit_id=NULLIF($5,''),version=version+1,updated_at=now()
|
||||
WHERE id=$1::uuid AND version=$2 AND state IN ('draft','validated')
|
||||
RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, auditID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
return revision, err
|
||||
}
|
||||
|
||||
func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64) (identity.Revision, bool, error) {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
if err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if ok, err := hasBreakGlassManager(ctx, tx); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
} else if !ok {
|
||||
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 {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
if !tenantExists {
|
||||
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,''),
|
||||
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)
|
||||
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
|
||||
WHERE id=$1::uuid AND version=$2 AND state='validated' FOR UPDATE`, id, expectedVersion).Scan(
|
||||
&nextIssuer, &nextTenant, &nextAudience, &nextClient, &nextSessionRef,
|
||||
); errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, false, identity.ErrRevisionConflict
|
||||
} else if err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
if previousID != "" {
|
||||
if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',superseded_at=now(),
|
||||
version=version+1,updated_at=now() WHERE id=$1::uuid AND state='active'`, previousID); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
}
|
||||
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='active',
|
||||
activated_at=now(),superseded_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='validated'
|
||||
RETURNING `+identityRevisionColumns, id, expectedVersion))
|
||||
if err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
identityChanged := previousID != "" && (previousIssuer != nextIssuer || previousTenant != nextTenant || previousAudience != nextAudience || previousClient != nextClient || previousSessionRef != nextSessionRef)
|
||||
if identityChanged {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return identity.Revision{}, false, err
|
||||
}
|
||||
return revision, identityChanged, nil
|
||||
}
|
||||
|
||||
func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64) (identity.Revision, error) {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if ok, err := hasBreakGlassManager(ctx, tx); err != nil {
|
||||
return identity.Revision{}, err
|
||||
} else if !ok {
|
||||
return identity.Revision{}, identity.ErrBreakGlassRequired
|
||||
}
|
||||
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',
|
||||
superseded_at=now(),version=version+1,updated_at=now() WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.Revision{}, identity.ErrRevisionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func (s *Store) HasBreakGlassManager(ctx context.Context) (bool, error) {
|
||||
return hasBreakGlassManager(ctx, s.pool)
|
||||
}
|
||||
|
||||
func (s *Store) HasActiveTenantKey(ctx context.Context, tenantKey string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=$1 AND status='active')`, tenantKey).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func hasBreakGlassManager(ctx context.Context, query interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}) (bool, error) {
|
||||
var exists bool
|
||||
err := query.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_users WHERE source='gateway' AND status='active'
|
||||
AND deleted_at IS NULL AND password_hash IS NOT NULL AND password_hash <> '' AND (roles ? 'manager' OR roles ? 'admin'))`).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func scanIdentityRevision(row scanner) (identity.Revision, error) {
|
||||
var revision identity.Revision
|
||||
var state string
|
||||
var scopes, capabilities []byte
|
||||
if err := row.Scan(
|
||||
&revision.ID, &state, &revision.SchemaVersion, &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.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds,
|
||||
&revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory,
|
||||
&revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt,
|
||||
&revision.CreatedAt, &revision.UpdatedAt,
|
||||
); err != nil {
|
||||
return identity.Revision{}, err
|
||||
}
|
||||
revision.State = identity.RevisionState(state)
|
||||
_ = json.Unmarshal(scopes, &revision.Scopes)
|
||||
_ = json.Unmarshal(capabilities, &revision.Capabilities)
|
||||
if revision.Scopes == nil {
|
||||
revision.Scopes = []string{}
|
||||
}
|
||||
if revision.Capabilities == nil {
|
||||
revision.Capabilities = []string{}
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func normalizeIdentityRevisionError(err error) error {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return identity.ErrRevisionNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','validated','active','superseded','failed')),
|
||||
schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1),
|
||||
auth_center_url text NOT NULL,
|
||||
issuer text,
|
||||
tenant_id text,
|
||||
application_id text,
|
||||
audience text,
|
||||
browser_client_id text,
|
||||
machine_client_id text,
|
||||
scopes jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(scopes) = 'array'),
|
||||
capabilities jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(capabilities) = 'array'),
|
||||
role_prefix text NOT NULL DEFAULT 'gateway.',
|
||||
local_tenant_key text NOT NULL,
|
||||
public_base_url text NOT NULL,
|
||||
web_base_url text NOT NULL,
|
||||
jit_enabled boolean NOT NULL DEFAULT true,
|
||||
legacy_jwt_enabled boolean NOT NULL DEFAULT false,
|
||||
token_introspection boolean NOT NULL DEFAULT false,
|
||||
session_revocation boolean NOT NULL DEFAULT false,
|
||||
machine_credential_ref text,
|
||||
session_encryption_key_ref text,
|
||||
session_idle_seconds integer NOT NULL DEFAULT 1800 CHECK (session_idle_seconds > 0),
|
||||
session_absolute_seconds integer NOT NULL DEFAULT 28800 CHECK (session_absolute_seconds > session_idle_seconds),
|
||||
session_refresh_seconds integer NOT NULL DEFAULT 60 CHECK (session_refresh_seconds > 0 AND session_refresh_seconds < session_idle_seconds),
|
||||
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
last_error_category text,
|
||||
last_trace_id text,
|
||||
last_audit_id text,
|
||||
validated_at timestamptz,
|
||||
activated_at timestamptz,
|
||||
superseded_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (machine_credential_ref IS NULL OR machine_credential_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'),
|
||||
CHECK (session_encryption_key_ref IS NULL OR session_encryption_key_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_identity_configuration_single_active
|
||||
ON gateway_identity_configuration_revisions ((true)) WHERE state = 'active';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_identity_configuration_revisions_created
|
||||
ON gateway_identity_configuration_revisions(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_identity_management_requests (
|
||||
operation text NOT NULL,
|
||||
idempotency_key text NOT NULL,
|
||||
request_hash text NOT NULL,
|
||||
response jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (operation, idempotency_key)
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user