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") ErrActiveConfigurationHandoffRequired = errors.New("active identity configuration requires an explicit remote resource handoff before re-pairing") ErrRollbackConfigurationHandoffRequired = errors.New("rollback requires a fresh remote resource handoff") ErrSecurityEventRetirementPending = errors.New("active security event connection must retire before identity can be disabled") ) type RevisionPolicy struct { LocalTenantKey string `json:"localTenantKey"` RolePrefix string `json:"rolePrefix"` JITEnabled bool `json:"jitEnabled"` LegacyJWTEnabled bool `json:"legacyJwtEnabled"` SessionIdleSeconds int `json:"sessionIdleSeconds"` SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"` SessionRefreshSeconds int `json:"sessionRefreshSeconds"` } func (policy RevisionPolicy) Validate() error { if strings.TrimSpace(policy.LocalTenantKey) == "" || strings.TrimSpace(policy.RolePrefix) == "" { return errors.New("local tenant mapping and role prefix are required") } if policy.SessionIdleSeconds <= 0 || policy.SessionAbsoluteSeconds <= policy.SessionIdleSeconds || policy.SessionRefreshSeconds <= 0 || policy.SessionRefreshSeconds >= policy.SessionIdleSeconds { return errors.New("identity session policy is invalid") } return nil } 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"` SecurityEventIssuer string `json:"securityEventIssuer,omitempty"` SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"` SecurityEventAudience string `json:"securityEventAudience,omitempty"` 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 AppEnv string } func NewDraft(input PairingInput, appEnv string) (Revision, error) { if _, err := input.ConsumerMetadata(false, appEnv); err != nil { return Revision{}, err } authCenter, _ := exactBaseURL(input.AuthCenterURL, appEnv) publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv) webBase, _ := exactBaseURL(input.WebBaseURL, appEnv) return Revision{ ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1, AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey), 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(input.AppEnv); 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"] if input.Manifest.SecurityEvents != nil { revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint revision.SecurityEventAudience = input.Manifest.SecurityEvents.Audience } 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, appEnv string) (ConsumerMetadata, error) { authCenter, err := exactBaseURL(input.AuthCenterURL, appEnv) if err != nil { return ConsumerMetadata{}, errors.New("auth center URL is invalid") } _ = authCenter publicBase, err := exactBaseURL(input.PublicBaseURL, appEnv) if err != nil { return ConsumerMetadata{}, errors.New("public base URL is invalid") } webBase, err := exactBaseURL(input.WebBaseURL, appEnv) 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, appEnv string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" { return "", errors.New("invalid public URL") } 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" && isLocalIdentityEnvironment(appEnv) && 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 isLocalIdentityEnvironment(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "development", "dev", "local", "test": return true default: return false } } // ValidateRevisionURLs re-applies the deployment environment URL policy when // constructing a Runtime. This protects against legacy or directly persisted // revisions bypassing the pairing boundary. func ValidateRevisionURLs(revision Revision, appEnv string) error { urls := []struct { label string raw string base bool }{ {label: "auth center", raw: revision.AuthCenterURL, base: true}, {label: "issuer", raw: revision.Issuer}, {label: "public base", raw: revision.PublicBaseURL, base: true}, {label: "web base", raw: revision.WebBaseURL, base: true}, } for _, candidate := range urls { var err error if candidate.base { _, err = exactBaseURL(candidate.raw, appEnv) } else { err = validatePublicIdentityURL(candidate.raw, appEnv) } if err != nil { return errors.New("identity " + candidate.label + " URL must use HTTPS in this environment") } } if revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" { if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil || validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil { return errors.New("identity security event URLs must use HTTPS in this environment") } } return nil } func isLoopbackHost(host string) bool { if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() }