Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions domain/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand Down
25 changes: 24 additions & 1 deletion internal/handler/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
}

Expand Down Expand Up @@ -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"`
}
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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())
}
Expand Down Expand Up @@ -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")
}
Expand Down
41 changes: 41 additions & 0 deletions internal/service/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Comment thread
rsharath marked this conversation as resolved.

// Resolve the identity policy: a caller-supplied policy ID must be
// tenant-scoped (IDOR guard via GetPolicy). When absent, assign the
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Comment thread
rsharath marked this conversation as resolved.
identity.UpdatedAt = time.Now()
if err := s.repo.Update(ctx, identity); err != nil {
return nil, err
Expand Down
14 changes: 14 additions & 0 deletions migrations/016_identity_risk_metadata.down.sql
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 28 additions & 0 deletions migrations/016_identity_risk_metadata.up.sql
Original file line number Diff line number Diff line change
@@ -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'));
125 changes: 125 additions & 0 deletions tests/integration/identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}