diff --git a/domain/identity.go b/domain/identity.go index 79174df..184160c 100644 --- a/domain/identity.go +++ b/domain/identity.go @@ -184,6 +184,54 @@ func (s IdentityStatus) CanTransitionTo(target IdentityStatus) bool { } } +// ────────────────────────────────────────────────────────────────────────────── +// Risk + assurance metadata enums (CoSAI §3.2 capability–risk matrix + +// NIST SP 800-63 Identity Assurance Levels referenced in CoSAI §3.5). +// Empty string is the "unclassified" default and is always valid. +// ────────────────────────────────────────────────────────────────────────────── + +const ( + CapabilityTierLow = "low" + CapabilityTierHigh = "high" + + RiskTierLow = "low" + RiskTierHigh = "high" + + IAL1 = "ial1" + IAL2 = "ial2" + IAL3 = "ial3" +) + +// ValidCapabilityTier reports whether v is a valid CapabilityTier value. +// Empty string is allowed and means "unclassified." +func ValidCapabilityTier(v string) bool { + switch v { + case "", CapabilityTierLow, CapabilityTierHigh: + return true + } + return false +} + +// ValidRiskTier reports whether v is a valid RiskTier value. +// Empty string is allowed and means "unclassified." +func ValidRiskTier(v string) bool { + switch v { + case "", RiskTierLow, RiskTierHigh: + return true + } + return false +} + +// ValidIAL reports whether v is a valid IAL (Identity Assurance Level). +// Empty string is allowed and means "unclassified." +func ValidIAL(v string) bool { + switch v { + case "", IAL1, IAL2, IAL3: + return true + } + return false +} + // IsUsable reports whether an identity in this status can authenticate and receive tokens. func (s IdentityStatus) IsUsable() bool { return s == IdentityStatusActive @@ -249,6 +297,26 @@ type Identity struct { // data, use AllowedScopes or Capabilities. Metadata json.RawMessage `bun:"metadata,type:jsonb" json:"metadata"` + // Risk + assurance metadata. Optional classification fields aligned with + // vendor-neutral standards bodies; consumed by future default-policy + // selection (e.g. shorter TTL for high-risk agents, mandatory attestation + // above IAL-2). Empty string means "unclassified" and is the safe default + // for existing rows. + // + // CapabilityTier and RiskTier follow the CoSAI Agentic IAM §3.2 + // capability–risk matrix (low × high crossed both axes). + // IAL follows NIST SP 800-63 Identity Assurance Levels (referenced in + // CoSAI §3.5). + // + // Spec: https://github.com/cosai-oasis/ws4-secure-design-agentic-systems/blob/main/agentic-identity-and-access-control.md + // + // `nullzero` so an empty Go string round-trips as SQL NULL — the CHECK + // constraint on each column accepts NULL or one of the enum values, so + // "" would otherwise violate it. + CapabilityTier string `bun:"capability_tier,type:varchar(20),nullzero" json:"capability_tier,omitempty"` + RiskTier string `bun:"risk_tier,type:varchar(20),nullzero" json:"risk_tier,omitempty"` + IAL string `bun:"ial,type:varchar(20),nullzero" json:"ial,omitempty"` + // Lifecycle CreatedBy string `bun:"created_by,type:varchar(255)" json:"created_by,omitempty"` ModifiedBy string `bun:"modified_by,type:varchar(255)" json:"modified_by,omitempty"` diff --git a/internal/handler/identity.go b/internal/handler/identity.go index e752b40..31ba4f3 100644 --- a/internal/handler/identity.go +++ b/internal/handler/identity.go @@ -33,6 +33,12 @@ type CreateIdentityInput struct { Description string `json:"description,omitempty" doc:"Human-readable description of the identity"` Capabilities json.RawMessage `json:"capabilities,omitempty" doc:"JSON array of capabilities"` Labels json.RawMessage `json:"labels,omitempty" doc:"JSON object of key-value labels"` + // CoSAI §3.2 capability–risk classification + NIST SP 800-63 IAL. + // Empty string is the default ("unclassified"); future default-policy + // selection will key off these. + CapabilityTier string `json:"capability_tier,omitempty" enum:"low,high" doc:"CoSAI §3.2 capability tier"` + RiskTier string `json:"risk_tier,omitempty" enum:"low,high" doc:"CoSAI §3.2 risk tier"` + IAL string `json:"ial,omitempty" enum:"ial1,ial2,ial3" doc:"NIST SP 800-63 Identity Assurance Level"` } } @@ -82,6 +88,11 @@ type UpdateIdentityInput struct { Labels json.RawMessage `json:"labels,omitempty" doc:"Key-value labels"` Metadata json.RawMessage `json:"metadata,omitempty" doc:"Product-specific metadata"` Status *string `json:"status,omitempty" enum:"active,suspended,deactivated" doc:"Identity status"` + // CoSAI §3.2 + NIST SP 800-63. Pointer so callers can distinguish + // "not set" (omit) from "clear to unclassified" (explicit ""). + CapabilityTier *string `json:"capability_tier,omitempty" enum:"low,high" doc:"CoSAI §3.2 capability tier"` + RiskTier *string `json:"risk_tier,omitempty" enum:"low,high" doc:"CoSAI §3.2 risk tier"` + IAL *string `json:"ial,omitempty" enum:"ial1,ial2,ial3" doc:"NIST SP 800-63 Identity Assurance Level"` } } @@ -193,6 +204,9 @@ func (a *API) createIdentityOp(ctx context.Context, input *CreateIdentityInput) Labels: input.Body.Labels, CreatedBy: createdBy, CredentialPolicyID: input.Body.CredentialPolicyID, + CapabilityTier: input.Body.CapabilityTier, + RiskTier: input.Body.RiskTier, + IAL: input.Body.IAL, }) if err != nil { if errors.Is(err, service.ErrIdentityAlreadyExists) { @@ -203,7 +217,8 @@ func (a *API) createIdentityOp(ctx context.Context, input *CreateIdentityInput) if errors.Is(err, service.ErrPolicyNotFound) { return nil, huma.Error400BadRequest("credential policy not found in this tenant") } - // SPIFFE path-segment validation failures are caller-fixable. + // SPIFFE path-segment + risk/IAL enum validation failures are + // caller-fixable. Service layer wraps both with ErrInvalidIdentityField. if errors.Is(err, service.ErrInvalidIdentityField) { return nil, huma.Error400BadRequest(err.Error()) } @@ -302,11 +317,19 @@ func (a *API) updateIdentityOp(ctx context.Context, input *UpdateIdentityInput) Metadata: input.Body.Metadata, Status: status, CredentialPolicyID: input.Body.CredentialPolicyID, + CapabilityTier: input.Body.CapabilityTier, + RiskTier: input.Body.RiskTier, + IAL: input.Body.IAL, }) if err != nil { if errors.Is(err, service.ErrPolicyNotFound) { return nil, huma.Error400BadRequest("credential policy not found in this tenant") } + // Field validation failures (SPIFFE path segments + risk/IAL enums) + // are caller-fixable. Service layer wraps them with ErrInvalidIdentityField. + if errors.Is(err, service.ErrInvalidIdentityField) { + return nil, huma.Error400BadRequest(err.Error()) + } log.Error().Err(err).Str("identity_id", input.ID).Msg("failed to update identity") return nil, huma.Error500InternalServerError("failed to update identity") } diff --git a/internal/service/identity.go b/internal/service/identity.go index f9f0648..f61ce0a 100644 --- a/internal/service/identity.go +++ b/internal/service/identity.go @@ -111,6 +111,11 @@ type RegisterIdentityRequest struct { // policy is assigned. Must exist within the caller's tenant; cross-tenant // IDs are rejected with ErrPolicyNotFound. CredentialPolicyID string + // Risk + assurance classification (CoSAI §3.2 / NIST SP 800-63). + // Empty strings are valid and mean "unclassified." + CapabilityTier string + RiskTier string + IAL string } // RegisterIdentity creates a new identity with a WIMSE URI. @@ -161,6 +166,15 @@ func (s *IdentityService) RegisterIdentity(ctx context.Context, req RegisterIden if err := validateECPublicKeyPEM(req.PublicKeyPEM); err != nil { return nil, err } + if !domain.ValidCapabilityTier(req.CapabilityTier) { + return nil, fmt.Errorf("%w: invalid capability_tier: %q (allowed: low, high, or empty)", ErrInvalidIdentityField, req.CapabilityTier) + } + if !domain.ValidRiskTier(req.RiskTier) { + return nil, fmt.Errorf("%w: invalid risk_tier: %q (allowed: low, high, or empty)", ErrInvalidIdentityField, req.RiskTier) + } + if !domain.ValidIAL(req.IAL) { + return nil, fmt.Errorf("%w: invalid ial: %q (allowed: ial1, ial2, ial3, or empty)", ErrInvalidIdentityField, req.IAL) + } // Resolve the identity policy: a caller-supplied policy ID must be // tenant-scoped (IDOR guard via GetPolicy). When absent, assign the @@ -193,6 +207,9 @@ func (s *IdentityService) RegisterIdentity(ctx context.Context, req RegisterIden Capabilities: req.Capabilities, Labels: req.Labels, Metadata: req.Metadata, + CapabilityTier: req.CapabilityTier, + RiskTier: req.RiskTier, + IAL: req.IAL, CreatedBy: req.CreatedBy, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -254,6 +271,12 @@ type UpdateIdentityRequest struct { // "clear to tenant default". A non-empty value must exist in the caller's // tenant; an empty string reassigns the tenant default. CredentialPolicyID *string + // Risk + assurance classification (CoSAI §3.2 / NIST SP 800-63). Pointer + // so callers can distinguish "not set" from "clear to unclassified" via + // an explicit empty-string assignment. + CapabilityTier *string + RiskTier *string + IAL *string } // UpdateIdentity updates mutable fields of an existing identity. @@ -329,6 +352,24 @@ func (s *IdentityService) UpdateIdentity(ctx context.Context, id, accountID, pro } identity.CredentialPolicyID = policyID } + if req.CapabilityTier != nil { + if !domain.ValidCapabilityTier(*req.CapabilityTier) { + return nil, fmt.Errorf("%w: invalid capability_tier: %q (allowed: low, high, or empty)", ErrInvalidIdentityField, *req.CapabilityTier) + } + identity.CapabilityTier = *req.CapabilityTier + } + if req.RiskTier != nil { + if !domain.ValidRiskTier(*req.RiskTier) { + return nil, fmt.Errorf("%w: invalid risk_tier: %q (allowed: low, high, or empty)", ErrInvalidIdentityField, *req.RiskTier) + } + identity.RiskTier = *req.RiskTier + } + if req.IAL != nil { + if !domain.ValidIAL(*req.IAL) { + return nil, fmt.Errorf("%w: invalid ial: %q (allowed: ial1, ial2, ial3, or empty)", ErrInvalidIdentityField, *req.IAL) + } + identity.IAL = *req.IAL + } identity.UpdatedAt = time.Now() if err := s.repo.Update(ctx, identity); err != nil { return nil, err diff --git a/migrations/016_identity_risk_metadata.down.sql b/migrations/016_identity_risk_metadata.down.sql new file mode 100644 index 0000000..bd939ce --- /dev/null +++ b/migrations/016_identity_risk_metadata.down.sql @@ -0,0 +1,14 @@ +-- 016_identity_risk_metadata.down.sql +-- Reverses 016_identity_risk_metadata.up.sql by dropping the constraints +-- and columns. Drop constraints first so the column DROP doesn't trip on +-- a constraint that no longer applies. + +ALTER TABLE identities + DROP CONSTRAINT IF EXISTS identities_capability_tier_check, + DROP CONSTRAINT IF EXISTS identities_risk_tier_check, + DROP CONSTRAINT IF EXISTS identities_ial_check; + +ALTER TABLE identities + DROP COLUMN IF EXISTS capability_tier, + DROP COLUMN IF EXISTS risk_tier, + DROP COLUMN IF EXISTS ial; diff --git a/migrations/016_identity_risk_metadata.up.sql b/migrations/016_identity_risk_metadata.up.sql new file mode 100644 index 0000000..bf934a1 --- /dev/null +++ b/migrations/016_identity_risk_metadata.up.sql @@ -0,0 +1,28 @@ +-- 016_identity_risk_metadata.up.sql +-- Adds three optional metadata columns to identities for CoSAI §3.2 +-- capability–risk classification + NIST SP 800-63 Identity Assurance Levels. +-- +-- All three are nullable. Existing rows default to NULL ("unclassified"). +-- CHECK constraints enforce the enum values at the DB layer; the service +-- layer also validates so callers get structured 400s instead of 23514 +-- constraint-violation errors. +-- +-- Spec: +-- https://github.com/cosai-oasis/ws4-secure-design-agentic-systems/blob/main/agentic-identity-and-access-control.md + +ALTER TABLE identities + ADD COLUMN IF NOT EXISTS capability_tier VARCHAR(20), + ADD COLUMN IF NOT EXISTS risk_tier VARCHAR(20), + ADD COLUMN IF NOT EXISTS ial VARCHAR(20); + +ALTER TABLE identities + ADD CONSTRAINT identities_capability_tier_check + CHECK (capability_tier IS NULL OR capability_tier IN ('low', 'high')); + +ALTER TABLE identities + ADD CONSTRAINT identities_risk_tier_check + CHECK (risk_tier IS NULL OR risk_tier IN ('low', 'high')); + +ALTER TABLE identities + ADD CONSTRAINT identities_ial_check + CHECK (ial IS NULL OR ial IN ('ial1', 'ial2', 'ial3')); diff --git a/tests/integration/identity_test.go b/tests/integration/identity_test.go index 36cd8b8..a9e5c28 100644 --- a/tests/integration/identity_test.go +++ b/tests/integration/identity_test.go @@ -497,3 +497,128 @@ func TestListIdentitiesEndpointFilters(t *testing.T) { assert.NotNil(t, body["limit"], "response should include limit") assert.NotNil(t, body["offset"], "response should include offset") } + +// TestRegisterIdentityWithRiskMetadata pins the optional CoSAI §3.2 + +// NIST SP 800-63 fields added by #80: capability_tier, risk_tier, ial. +// Valid enum values must round-trip through register → GET; invalid +// values must surface as a structured 400 instead of a constraint error. +func TestRegisterIdentityWithRiskMetadata(t *testing.T) { + externalID := uid("risk-meta-agent") + resp := post(t, adminPath("/identities"), map[string]any{ + "external_id": externalID, + "trust_level": "first_party", + "owner_user_id": "user-test-owner", + "capability_tier": "high", + "risk_tier": "high", + "ial": "ial2", + }, adminHeaders()) + require.Equal(t, http.StatusCreated, resp.StatusCode) + body := decode(t, resp) + id := body["id"].(string) + assert.Equal(t, "high", body["capability_tier"]) + assert.Equal(t, "high", body["risk_tier"]) + assert.Equal(t, "ial2", body["ial"]) + + // GET round-trip — values must persist. + getResp := get(t, adminPath("/identities/"+id), adminHeaders()) + defer func() { _ = getResp.Body.Close() }() + require.Equal(t, http.StatusOK, getResp.StatusCode) + got := decode(t, getResp) + assert.Equal(t, "high", got["capability_tier"]) + assert.Equal(t, "high", got["risk_tier"]) + assert.Equal(t, "ial2", got["ial"]) +} + +// TestRegisterIdentityRiskMetadataDefaultsUnclassified verifies that +// omitting the new fields leaves them empty rather than blowing up the +// CHECK constraint. `nullzero` on the bun column tag is what makes "" → NULL. +func TestRegisterIdentityRiskMetadataDefaultsUnclassified(t *testing.T) { + externalID := uid("risk-meta-default") + resp := post(t, adminPath("/identities"), map[string]any{ + "external_id": externalID, + "trust_level": "unverified", + "owner_user_id": "user-test-owner", + }, adminHeaders()) + require.Equal(t, http.StatusCreated, resp.StatusCode) + body := decode(t, resp) + // omitempty + zero string → not in response at all. + _, hasCap := body["capability_tier"] + _, hasRisk := body["risk_tier"] + _, hasIAL := body["ial"] + assert.False(t, hasCap, "capability_tier must be absent when unset") + assert.False(t, hasRisk, "risk_tier must be absent when unset") + assert.False(t, hasIAL, "ial must be absent when unset") +} + +// TestRegisterIdentityRejectsInvalidRiskMetadata verifies enum validation +// catches bad values before they reach the DB CHECK constraint. Huma's +// `enum:` schema validator runs first and produces 422 for the OpenAPI- +// driven path; the service-layer validator (which produces 400) is the +// fallback for callers that skip the schema. Either is correct — what +// matters is that a SQLSTATE 23514 never leaks back as 500. +func TestRegisterIdentityRejectsInvalidRiskMetadata(t *testing.T) { + cases := []struct { + name string + field string + value string + }{ + {"capability_tier_unknown", "capability_tier", "medium"}, + {"risk_tier_unknown", "risk_tier", "extreme"}, + {"ial_unknown", "ial", "ial4"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := map[string]any{ + "external_id": uid("bad-" + tc.field), + "trust_level": "unverified", + "owner_user_id": "user-test-owner", + tc.field: tc.value, + } + resp := post(t, adminPath("/identities"), body, adminHeaders()) + defer func() { _ = resp.Body.Close() }() + ok := resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnprocessableEntity + assert.Truef(t, ok, + "invalid %s=%q must surface as a structured 400 or 422, got %d (a 500 from a CHECK violation would mean validation skipped)", + tc.field, tc.value, resp.StatusCode) + }) + } +} + +// TestUpdateIdentityRiskMetadata verifies PATCH-style updates to the new +// fields land via the admin API. The enum schema only admits the +// real values (low/high, ial1/ial2/ial3); "clear back to unclassified" +// is intentionally NOT exposed — once an operator has classified an +// agent, the classification stays unless rotated through valid values. +// Empty-string clear at the SQL layer is still possible via direct DB +// manipulation, but it's not part of the API contract. +func TestUpdateIdentityRiskMetadata(t *testing.T) { + externalID := uid("risk-meta-update") + resp := post(t, adminPath("/identities"), map[string]any{ + "external_id": externalID, + "trust_level": "unverified", + "owner_user_id": "user-test-owner", + }, adminHeaders()) + require.Equal(t, http.StatusCreated, resp.StatusCode) + id := decode(t, resp)["id"].(string) + + updateResp := doRequest(t, http.MethodPatch, adminPath("/identities/"+id), map[string]any{ + "capability_tier": "low", + "risk_tier": "high", + "ial": "ial3", + }, adminHeaders()) + require.Equal(t, http.StatusOK, updateResp.StatusCode) + body := decode(t, updateResp) + assert.Equal(t, "low", body["capability_tier"]) + assert.Equal(t, "high", body["risk_tier"]) + assert.Equal(t, "ial3", body["ial"]) + + // Re-classification (low → high) must also land. + reclassifyResp := doRequest(t, http.MethodPatch, adminPath("/identities/"+id), map[string]any{ + "capability_tier": "high", + }, adminHeaders()) + require.Equal(t, http.StatusOK, reclassifyResp.StatusCode) + reclassified := decode(t, reclassifyResp) + assert.Equal(t, "high", reclassified["capability_tier"]) + assert.Equal(t, "high", reclassified["risk_tier"], "risk_tier should be unchanged by capability_tier update") + assert.Equal(t, "ial3", reclassified["ial"], "ial should be unchanged by capability_tier update") +}