Files
mcias/internal/policy/policy.go
Kyle Isom f262ca7b4e UI: password change enforcement + migration recovery
- Web UI admin password reset now enforces admin role
  server-side (was cookie-auth + CSRF only; any logged-in
  user could previously reset any account's password)
- Added self-service password change UI at GET/PUT /profile:
  current_password + new_password + confirm_password;
  server-side equality check; lockout + Argon2id verification;
  revokes all other sessions on success
- password_change_form.html fragment and profile.html page
- Nav bar actor name now links to /profile
- policy: ActionChangePassword + default rule -7 allowing
  human accounts to change their own password
- openapi.yaml: built-in rules count updated to -7

Migration recovery:
- mciasdb schema force --version N: new subcommand to clear
  dirty migration state without running SQL (break-glass)
- schema subcommands bypass auto-migration on open so the
  tool stays usable when the database is dirty
- Migrate(): shim no longer overrides schema_migrations
  when it already has an entry; duplicate-column error on
  the latest migration is force-cleaned and treated as
  success (handles columns added outside the runner)

Security:
- Admin role is now validated in handleAdminResetPassword
  before any DB access; non-admin receives 403
- handleSelfChangePassword follows identical lockout +
  constant-time Argon2id path as the REST self-service
  handler; current password required to prevent
  token-theft account takeover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:33:19 -07:00

143 lines
5.4 KiB
Go

// Package policy implements an in-process, attribute-based authorization
// policy engine for MCIAS. Evaluation is a pure function: given a PolicyInput
// and a slice of Rules it returns an Effect (Allow or Deny) and the Rule that
// produced the decision. The caller is responsible for assembling PolicyInput
// from JWT claims and database lookups; the engine never touches the database.
//
// Evaluation order:
// 1. Rules are sorted by Priority (ascending; lower = higher precedence).
// 2. Deny-wins: the first matching Deny rule terminates evaluation.
// 3. If no Deny matched, the first matching Allow rule permits the request.
// 4. Default-deny: if no rule matches, the request is denied.
package policy
// Action is a structured action identifier of the form "resource:verb".
// Security: using typed constants prevents callers from passing arbitrary
// strings, making it harder to accidentally bypass a policy check.
type Action string
const (
ActionListAccounts Action = "accounts:list"
ActionCreateAccount Action = "accounts:create"
ActionReadAccount Action = "accounts:read"
ActionUpdateAccount Action = "accounts:update"
ActionDeleteAccount Action = "accounts:delete"
ActionReadRoles Action = "roles:read"
ActionWriteRoles Action = "roles:write"
ActionReadTags Action = "tags:read"
ActionWriteTags Action = "tags:write"
ActionIssueToken Action = "tokens:issue"
ActionRevokeToken Action = "tokens:revoke"
ActionValidateToken Action = "tokens:validate" // public endpoint
ActionRenewToken Action = "tokens:renew" // self-service
ActionReadPGCreds Action = "pgcreds:read"
ActionWritePGCreds Action = "pgcreds:write"
ActionReadAudit Action = "audit:read"
ActionEnrollTOTP Action = "totp:enroll" // self-service
ActionRemoveTOTP Action = "totp:remove" // admin
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionChangePassword Action = "auth:change_password" // self-service
ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage"
)
// ResourceType identifies what kind of object a request targets.
type ResourceType string
const (
ResourceAccount ResourceType = "account"
ResourceToken ResourceType = "token"
ResourcePGCreds ResourceType = "pgcreds"
ResourceAuditLog ResourceType = "audit_log"
ResourceTOTP ResourceType = "totp"
ResourcePolicy ResourceType = "policy"
)
// Effect is the outcome of policy evaluation.
type Effect string
const (
Allow Effect = "allow"
Deny Effect = "deny"
)
// Resource describes the object the principal is attempting to act on. Tags
// are loaded from the account_tags table by the middleware before evaluation.
type Resource struct {
Type ResourceType
// OwnerUUID is the UUID of the account that owns this resource (e.g. the
// system account whose pg_credentials are being requested). Empty when the
// resource is not account-owned (e.g. an audit log listing).
OwnerUUID string
// ServiceName is the username of the system account that owns the resource.
// Used for service-name-based gating rules (ServiceNames field on Rule).
ServiceName string
// Tags are the account_tags values on the target account. The engine
// checks RequiredTags against this slice.
Tags []string
}
// PolicyInput is assembled by the middleware from JWT claims and the current
// request context. The engine accepts a PolicyInput and a rule set; it never
// queries the database directly.
type PolicyInput struct {
// Principal fields — from JWT claims
Subject string // account UUID ("sub")
AccountType string // "human" or "system"
Roles []string // role strings from "roles" claim
Action Action
Resource Resource
}
// Rule is a single policy statement. All non-zero fields are AND-ed together
// as match conditions. A zero/empty field is a wildcard.
//
// Security: rules from the database are decoded and merged with compiled-in
// defaults before evaluation. Neither the JSON encoding nor the DB storage is
// trusted to produce sensible rules; the engine validates each condition
// independently using set membership — there is no string interpolation or
// code execution involved.
type Rule struct {
Description string
SubjectUUID string
ResourceType ResourceType
Effect Effect
Roles []string
AccountTypes []string
Actions []Action
ServiceNames []string
RequiredTags []string
ID int64
Priority int
OwnerMatchesSubject bool
}
// RuleBody is the subset of Rule that is stored as JSON in the policy_rules
// table. ID, Description, and Priority are stored as dedicated columns.
// Security: the JSON blob is decoded into a RuleBody before being merged into
// a full Rule, so the database cannot inject ID or Description values.
type RuleBody struct {
SubjectUUID string `json:"subject_uuid,omitempty"`
ResourceType ResourceType `json:"resource_type,omitempty"`
Effect Effect `json:"effect"`
Roles []string `json:"roles,omitempty"`
AccountTypes []string `json:"account_types,omitempty"`
Actions []Action `json:"actions,omitempty"`
ServiceNames []string `json:"service_names,omitempty"`
RequiredTags []string `json:"required_tags,omitempty"`
OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"`
}