Skip to content
Open
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
33 changes: 33 additions & 0 deletions domain/constraint_catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package domain

import (
"encoding/json"
"time"

"github.com/uptrace/bun"
)

// ConstraintCatalogVersion is the persisted, ES256-signed snapshot of the
// active downstream policy set (e.g. Cedar policies enforced in Shield).
// ZeroID does not parse the document — it hashes the canonical bytes,
// signs them, and rewrites the SignedAt every 24h so consumers of the
// hash claim can detect a stale/replayed catalog.
//
// Multiple rows can share the same Hash (re-sign of unchanged content),
// distinguished by SignedAt. Only Hash is embedded in tokens, so a
// re-sign with identical Hash leaves outstanding tokens valid.
type ConstraintCatalogVersion struct {
bun.BaseModel `bun:"table:constraint_catalog_versions,alias:ccv"`

ID string `bun:"id,pk,type:uuid" json:"id"`
AccountID string `bun:"account_id,type:varchar(255)" json:"account_id"`
ProjectID string `bun:"project_id,type:varchar(255)" json:"project_id"`
Version string `bun:"version,type:varchar(64)" json:"version"`
EffectiveAt time.Time `bun:"effective_at" json:"effective_at"`
Document json.RawMessage `bun:"document,type:jsonb" json:"document"`
Hash string `bun:"hash,type:varchar(80)" json:"hash"`
SignedAt time.Time `bun:"signed_at,nullzero,notnull,default:current_timestamp" json:"signed_at"`
Signature string `bun:"signature,type:text" json:"signature"`
SigningKeyID string `bun:"signing_key_id,type:varchar(255)" json:"signing_key_id"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
}
8 changes: 8 additions & 0 deletions domain/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ type IssuedCredential struct {
ParentJTI string `bun:"parent_jti,type:varchar(255)" json:"parent_jti,omitempty"`
// DelegatedByWIMSEURI records the orchestrator that delegated authority (RFC 8693 token_exchange).
DelegatedByWIMSEURI string `bun:"delegated_by_wimse_uri,type:text" json:"delegated_by_wimse_uri,omitempty"`
// DRMHash binds the credential to the SHA-256 of the DRM document
// active at issuance (issue #59). Empty when no DRM is configured for
// the tenant. Indexed for policy_drift detection.
DRMHash string `bun:"drm_hash,type:varchar(80)" json:"drm_hash,omitempty"`
// ConstraintCatalogHash binds the credential to the SHA-256 of the
// Constraint Catalog document active at issuance (issue #59). Empty
// when no catalog is configured for the tenant.
ConstraintCatalogHash string `bun:"constraint_catalog_hash,type:varchar(80)" json:"constraint_catalog_hash,omitempty"`
// MissionID is a stable, opaque identifier for a delegation tree —
// equal to the root credential's JTI today; consumers MUST treat it
// as opaque so the population scheme can evolve. Denormalised onto
Expand Down
85 changes: 85 additions & 0 deletions domain/decision_rights_matrix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package domain

import (
"errors"
"time"

"github.com/uptrace/bun"
)

// ErrDRMUnauthorized signals that the requested delegation (from→to pair,
// scopes, or resource set) is not permitted by the active DRM. Wrapped so
// callers can use errors.Is at the OAuth boundary to translate into an
// invalid_grant response (RFC 8693 §2.2.2).
var ErrDRMUnauthorized = errors.New("delegation not authorized by active decision-rights matrix")

// ErrDRMInvalid indicates the DRM document failed schema validation at
// admission time (missing version, empty allowed_delegations, malformed
// SPIFFE pattern). Surfaces as 400 from the admin handler.
var ErrDRMInvalid = errors.New("invalid decision-rights matrix document")

// DRMAllowedDelegation is one row of the DRM authorization table.
//
// `From` and `To` are SPIFFE URI patterns (`spiffe://domain/.../*` style).
// `Resources` and `Conditions` are kept as opaque maps/strings — the DRM
// is an authorization fence for *which delegations exist*, not a full
// policy engine; Cedar/OPA live downstream in Shield for per-request
// authorization.
type DRMAllowedDelegation struct {
From string `json:"from"`
To string `json:"to"`
Resources []string `json:"resources,omitempty"`
Conditions map[string]any `json:"conditions,omitempty"`
}

// DRMDocument is the canonical wire/storage shape of a DRM. The Hash
// stored alongside DecisionRightsMatrix is computed over the canonical
// JSON encoding (sorted keys) of this struct.
type DRMDocument struct {
Version string `json:"version"`
EffectiveAt time.Time `json:"effective_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AllowedDelegations []DRMAllowedDelegation `json:"allowed_delegations"`
}

// Validate enforces the minimum schema requirements: version present,
// effective_at set, at least one allowed_delegation, and every delegation
// has a non-empty from/to. We deliberately do not validate the SPIFFE
// pattern syntax here — the governance service does that during the
// authorization check so the failure mode is consistent across writes
// and reads.
func (d DRMDocument) Validate() error {
if d.Version == "" {
return errors.New("drm: version is required")
}
if d.EffectiveAt.IsZero() {
return errors.New("drm: effective_at is required")
}
if len(d.AllowedDelegations) == 0 {
return errors.New("drm: allowed_delegations must not be empty")
}
for i, rule := range d.AllowedDelegations {
if rule.From == "" || rule.To == "" {
return errors.New("drm: allowed_delegations: from and to are required")
}
_ = i
}
return nil
}

// DecisionRightsMatrix is the persisted DRM row. Rows are immutable —
// the database trigger drm_block_mutation refuses UPDATE/DELETE. A new
// version is published by inserting a new row.
type DecisionRightsMatrix struct {
bun.BaseModel `bun:"table:decision_rights_matrix,alias:drm"`

ID string `bun:"id,pk,type:uuid" json:"id"`
AccountID string `bun:"account_id,type:varchar(255)" json:"account_id"`
ProjectID string `bun:"project_id,type:varchar(255)" json:"project_id"`
Version string `bun:"version,type:varchar(64)" json:"version"`
EffectiveAt time.Time `bun:"effective_at" json:"effective_at"`
ExpiresAt *time.Time `bun:"expires_at" json:"expires_at,omitempty"`
Document DRMDocument `bun:"document,type:jsonb" json:"document"`
Hash string `bun:"hash,type:varchar(80)" json:"hash"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
}
7 changes: 6 additions & 1 deletion domain/signal.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ const (
SignalTypePolicyViolation SignalType = "policy_violation"
SignalTypeRetirement SignalType = "retirement"
SignalTypeOwnerChange SignalType = "owner_change"
// SignalTypePolicyDrift is emitted when the active DRM or Constraint
// Catalog hash diverges from the hash bound into outstanding tokens.
// Enforcement points use this signal to schedule re-evaluation on
// next use rather than immediate revocation — see issue #59.
SignalTypePolicyDrift SignalType = "policy_drift"
// SignalTypeIdentityExpired fires when the cleanup worker deactivates
// an identity whose expires_at has passed. Kept distinct from
// SignalTypeRetirement (admin-initiated deactivation) so subscribers
Expand Down Expand Up @@ -49,7 +54,7 @@ func (t SignalType) Valid() bool {
switch t {
case SignalTypeCredentialChange, SignalTypeSessionRevoked, SignalTypeIPChange,
SignalTypeAnomalousBehavior, SignalTypePolicyViolation, SignalTypeRetirement,
SignalTypeOwnerChange, SignalTypeIdentityExpired:
SignalTypeOwnerChange, SignalTypePolicyDrift, SignalTypeIdentityExpired:
return true
}
return false
Expand Down
188 changes: 188 additions & 0 deletions internal/handler/governance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package handler

import (
"context"
"encoding/json"
"errors"
"net/http"
"time"

"github.com/danielgtaylor/huma/v2"
"github.com/rs/zerolog/log"

"github.com/highflame-ai/zeroid/domain"
internalMiddleware "github.com/highflame-ai/zeroid/internal/middleware"
)

// ── DRM types ────────────────────────────────────────────────────────────────

type PublishDRMInput struct {
Body struct {
Version string `json:"version" required:"true" doc:"Semver version string"`
EffectiveAt time.Time `json:"effective_at" required:"true" doc:"When this DRM becomes active"`
ExpiresAt *time.Time `json:"expires_at,omitempty" doc:"When this DRM stops being active (optional)"`
AllowedDelegations []domain.DRMAllowedDelegation `json:"allowed_delegations" required:"true" minItems:"1" doc:"Permitted delegation rules"`
}
}

type DRMOutput struct {
Body *domain.DecisionRightsMatrix
}

type DRMListOutput struct {
Body struct {
DecisionRightsMatrix []*domain.DecisionRightsMatrix `json:"decision_rights_matrix"`
Total int `json:"total"`
}
}

type DRMIDInput struct {
ID string `path:"id" doc:"DRM row UUID"`
}

// ── Constraint Catalog types ─────────────────────────────────────────────────

type PublishCatalogInput struct {
Body struct {
Version string `json:"version" required:"true" doc:"Version identifier (e.g. ISO 8601 timestamp)"`
EffectiveAt time.Time `json:"effective_at" required:"true" doc:"When this catalog becomes active"`
Document json.RawMessage `json:"document" required:"true" doc:"Opaque policy document (ZeroID hashes + signs, does not parse)"`
}
}

type CatalogOutput struct {
Body *domain.ConstraintCatalogVersion
}

// ── Routes ───────────────────────────────────────────────────────────────────

func (a *API) registerGovernanceRoutes(api huma.API) {
if a.governanceSvc == nil {
// Governance binding is not configured for this deployment — skip
// route registration so the OpenAPI surface only exposes the
// endpoints that will actually work.
return
}

huma.Register(api, huma.Operation{
OperationID: "publish-drm",
Method: http.MethodPost,
Path: "/governance/decision-rights-matrix",
Summary: "Publish a new Decision-Rights Matrix",
Tags: []string{"Governance"},
DefaultStatus: http.StatusCreated,
}, a.publishDRMOp)

huma.Register(api, huma.Operation{
OperationID: "get-active-drm",
Method: http.MethodGet,
Path: "/governance/decision-rights-matrix/active",
Summary: "Get the currently active DRM",
Tags: []string{"Governance"},
}, a.getActiveDRMOp)

huma.Register(api, huma.Operation{
OperationID: "list-drm",
Method: http.MethodGet,
Path: "/governance/decision-rights-matrix",
Summary: "List DRM version history",
Tags: []string{"Governance"},
}, a.listDRMOp)

huma.Register(api, huma.Operation{
OperationID: "publish-constraint-catalog",
Method: http.MethodPost,
Path: "/governance/constraint-catalog",
Summary: "Publish a new Constraint Catalog version",
Tags: []string{"Governance"},
DefaultStatus: http.StatusCreated,
}, a.publishCatalogOp)

huma.Register(api, huma.Operation{
OperationID: "get-active-catalog",
Method: http.MethodGet,
Path: "/governance/constraint-catalog/active",
Summary: "Get the most recently signed Constraint Catalog row",
Tags: []string{"Governance"},
}, a.getActiveCatalogOp)
}

func (a *API) publishDRMOp(ctx context.Context, input *PublishDRMInput) (*DRMOutput, error) {
tenant, err := internalMiddleware.GetTenant(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("missing tenant context")
}
doc := domain.DRMDocument{
Version: input.Body.Version,
EffectiveAt: input.Body.EffectiveAt,
ExpiresAt: input.Body.ExpiresAt,
AllowedDelegations: input.Body.AllowedDelegations,
}
row, err := a.governanceSvc.PublishDRM(ctx, tenant.AccountID, tenant.ProjectID, doc)
if err != nil {
if errors.Is(err, domain.ErrDRMInvalid) {
return nil, huma.Error400BadRequest(err.Error())
}
log.Error().Err(err).Msg("publish DRM failed")
return nil, huma.Error500InternalServerError("failed to publish DRM")
}
return &DRMOutput{Body: row}, nil
}

func (a *API) getActiveDRMOp(ctx context.Context, _ *struct{}) (*DRMOutput, error) {
tenant, err := internalMiddleware.GetTenant(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("missing tenant context")
}
row, err := a.governanceSvc.GetActiveDRM(ctx, tenant.AccountID, tenant.ProjectID)
if err != nil {
return nil, huma.Error500InternalServerError("failed to get active DRM")
}
if row == nil {
return nil, huma.Error404NotFound("no active DRM")
}
return &DRMOutput{Body: row}, nil
}

func (a *API) listDRMOp(ctx context.Context, _ *struct{}) (*DRMListOutput, error) {
tenant, err := internalMiddleware.GetTenant(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("missing tenant context")
}
rows, err := a.governanceSvc.ListDRM(ctx, tenant.AccountID, tenant.ProjectID)
if err != nil {
return nil, huma.Error500InternalServerError("failed to list DRMs")
}
out := &DRMListOutput{}
out.Body.DecisionRightsMatrix = rows
out.Body.Total = len(rows)
return out, nil
}

func (a *API) publishCatalogOp(ctx context.Context, input *PublishCatalogInput) (*CatalogOutput, error) {
tenant, err := internalMiddleware.GetTenant(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("missing tenant context")
}
row, err := a.governanceSvc.PublishCatalog(ctx, tenant.AccountID, tenant.ProjectID, input.Body.Version, input.Body.EffectiveAt, input.Body.Document)
if err != nil {
log.Error().Err(err).Msg("publish catalog failed")
return nil, huma.Error500InternalServerError("failed to publish catalog")
}
return &CatalogOutput{Body: row}, nil
}

func (a *API) getActiveCatalogOp(ctx context.Context, _ *struct{}) (*CatalogOutput, error) {
tenant, err := internalMiddleware.GetTenant(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("missing tenant context")
}
row, err := a.governanceSvc.GetActiveCatalog(ctx, tenant.AccountID, tenant.ProjectID)
if err != nil {
return nil, huma.Error500InternalServerError("failed to get active catalog")
}
if row == nil {
return nil, huma.Error404NotFound("no active catalog")
}
return &CatalogOutput{Body: row}, nil
}
4 changes: 4 additions & 0 deletions internal/handler/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type API struct {
agentSvc *service.AgentService
auditSvc *service.AuditService
backchannelSvc *service.BackchannelService
governanceSvc *service.GovernanceService
dpopVerifier *dpop.Verifier
delegationSvc *service.DelegationService
jwksSvc *signing.JWKSService
Expand Down Expand Up @@ -78,6 +79,7 @@ func NewAPI(
agentSvc *service.AgentService,
auditSvc *service.AuditService,
backchannelSvc *service.BackchannelService,
governanceSvc *service.GovernanceService,
dpopVerifier *dpop.Verifier,
delegationSvc *service.DelegationService,
jwksSvc *signing.JWKSService,
Expand All @@ -99,6 +101,7 @@ func NewAPI(
agentSvc: agentSvc,
auditSvc: auditSvc,
backchannelSvc: backchannelSvc,
governanceSvc: governanceSvc,
dpopVerifier: dpopVerifier,
delegationSvc: delegationSvc,
jwksSvc: jwksSvc,
Expand Down Expand Up @@ -235,6 +238,7 @@ func (a *API) RegisterAdmin(api huma.API, router chi.Router) {
a.registerAuditRoutes(api)
a.registerBackchannelAdminRoutes(api)
a.registerExpiringSoonRoute(api)
a.registerGovernanceRoutes(api)
a.registerSigningCredentialRoutes(api)
a.registerDelegationRoutes(api)
}
Expand Down
Loading