diff --git a/.junie/memory/language.json b/.junie/memory/language.json index 27e664b..d7b90cb 100644 --- a/.junie/memory/language.json +++ b/.junie/memory/language.json @@ -1 +1 @@ -[{"lang":"en","usageCount":1}] \ No newline at end of file +[{"lang":"en","usageCount":3}] \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8f2cee6..08ac084 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -367,7 +367,25 @@ All endpoints use JSON request/response bodies. All responses include a | POST | `/v1/token/issue` | admin JWT | Issue service account token | | DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI | -### Account Endpoints (admin only) +### Token Download Endpoint + +| Method | Path | Auth required | Description | +|---|---|---|---| +| GET | `/token/download/{nonce}` | bearer JWT | Download a previously issued token via one-time nonce (5-min TTL, single-use) | + +The token download flow issues a short-lived nonce when a service token is created +via `POST /accounts/{id}/token`. The bearer must be authenticated; the nonce is +deleted on first download to prevent replay. This avoids exposing the raw token +value in an HTMX fragment or flash message. + +### Token Delegation Endpoints (admin only) + +| Method | Path | Auth required | Description | +|---|---|---|---| +| POST | `/accounts/{id}/token/delegates` | admin JWT | Grant a human account permission to issue tokens for a system account | +| DELETE | `/accounts/{id}/token/delegates/{grantee}` | admin JWT | Revoke token-issue delegation | + +### Account Endpoints | Method | Path | Auth required | Description | |---|---|---|---| @@ -376,6 +394,7 @@ All endpoints use JSON request/response bodies. All responses include a | GET | `/v1/accounts/{id}` | admin JWT | Get account details | | PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) | | DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account | +| POST | `/v1/accounts/{id}/token` | bearer JWT (admin or delegate) | Issue/rotate service account token | ### Password Endpoints @@ -479,6 +498,7 @@ cookie pattern (`mcias_csrf`). | `/policies` | Policy rules management — create, enable/disable, delete | | `/audit` | Audit log viewer | | `/profile` | User profile — self-service password change (any authenticated user) | +| `/service-accounts` | Delegated service account list for non-admin users; issue/rotate token with one-time download | **HTMX fragments:** Mutating operations (role updates, tag edits, credential saves, policy toggles, access grants) use HTMX partial-page updates for a @@ -658,6 +678,22 @@ CREATE TABLE policy_rules ( not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339) expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339) ); + +-- Token issuance delegation: tracks which human accounts may issue tokens for +-- a given system account without holding the global admin role. Admins manage +-- delegates; delegates can issue/rotate tokens for the specific system account +-- only and cannot modify any other account settings. +CREATE TABLE service_account_delegates ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- target system account + grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- human account granted access + granted_by INTEGER REFERENCES accounts(id), -- admin who granted access + granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE (account_id, grantee_id) +); + +CREATE INDEX idx_sa_delegates_account ON service_account_delegates (account_id); +CREATE INDEX idx_sa_delegates_grantee ON service_account_delegates (grantee_id); ``` ### Schema Notes @@ -810,6 +846,8 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into | `policy_rule_updated` | Policy rule updated (priority, enabled, description) | | `policy_rule_deleted` | Policy rule deleted | | `policy_deny` | Policy engine denied a request (logged for every explicit deny) | +| `token_delegate_granted` | Admin granted a human account permission to issue tokens for a system account | +| `token_delegate_revoked` | Admin revoked a human account's token-issue delegation | | `vault_unsealed` | Vault unsealed via REST API or web UI; details include `source` (api\|ui) and `ip` | | `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` | @@ -1373,6 +1411,8 @@ needed: - A human account should be able to access credentials for one specific service without being a full admin. +- A human account should be able to issue/rotate tokens for one specific service + account without holding the global `admin` role (see token delegation, §21). - A system account (`deploy-agent`) should only operate on hosts tagged `env:staging`, not `env:production`. - A "secrets reader" role should read pgcreds for any service but change nothing. @@ -1465,7 +1505,7 @@ type Resource struct { // Rule is a single policy statement. All populated fields are ANDed. // A zero/empty field is a wildcard (matches anything). type Rule struct { - ID int64 // database primary key; 0 for built-in rules + ID int64 // database primary key; negative for built-in rules (-1 … -7) Description string // Principal match conditions @@ -1681,3 +1721,94 @@ introduced. | `policy_rule_deleted` | Rule deleted | | `tag_added` | Tag added to an account | | `tag_removed` | Tag removed from an account | + +--- + +## 21. Token Issuance Delegation + +### Motivation + +The initial design required the `admin` role to issue a service account token. +This blocks a common workflow: a developer who owns one personal app (e.g. +`payments-api`) wants to rotate its service token without granting another +person full admin access to all of MCIAS. + +Token issuance delegation solves this by allowing admins to grant specific +human accounts the right to issue/rotate tokens for specific system accounts — +and nothing else. + +### Model + +The `service_account_delegates` table stores the delegation relationship: + +``` +service_account_delegates(account_id, grantee_id, granted_by, granted_at) +``` + +- `account_id` — the **system account** whose token the delegate may issue +- `grantee_id` — the **human account** granted the right +- `granted_by` — the admin who created the grant (for audit purposes) + +A human account is a delegate if a row exists with their ID as `grantee_id`. +Delegates may: + +- Issue/rotate the token for the specific system account +- Download the newly issued token via the one-time nonce endpoint +- View the system account on their `/service-accounts` page + +Delegates may **not**: + +- Modify roles, tags, or status on the system account +- Read or modify pgcreds for the system account +- List other accounts or perform any other admin operation + +### Token Download Flow + +Issuing a service token via `POST /accounts/{id}/token` (admin or delegate) +stores the raw token string in an in-memory `sync.Map` under a random nonce +with a 5-minute TTL. The handler returns the nonce in the HTMX fragment. + +The caller redeems the nonce via `GET /token/download/{nonce}`, which: + +1. Looks up the nonce in the map (missing → 404). +2. Deletes the nonce immediately (prevents replay). +3. Returns the token as `Content-Disposition: attachment; filename=token.txt`. + +The nonce is not stored in the database and is lost on server restart. This +is intentional: if the download window is missed, the operator simply issues +a new token. + +### Authorization Check + +`POST /accounts/{id}/token` is authenticated (bearer JWT + CSRF) but not +admin-only. The handler performs an explicit check: + +``` +if claims.HasRole("admin") OR db.HasTokenIssueAccess(targetID, callerID): + proceed +else: + 403 Forbidden +``` + +This check is done in the handler rather than middleware because the +delegation relationship requires a DB lookup that depends on the caller's +identity and the specific target account. + +### Admin Management + +| Endpoint | Description | +|---|---| +| `POST /accounts/{id}/token/delegates` | Grant delegation (admin only) | +| `DELETE /accounts/{id}/token/delegates/{grantee}` | Revoke delegation (admin only) | + +Both operations produce audit events (`token_delegate_granted`, +`token_delegate_revoked`) and are visible in the account detail UI under +the "Token Issue Access" section. + +### Audit Events + +| Event | Trigger | +|---|---| +| `token_delegate_granted` | Admin granted a human account token-issue access for a system account | +| `token_delegate_revoked` | Admin revoked token-issue delegation | +| `token_issued` | Token issued (existing event, also fires for delegate-issued tokens) | diff --git a/POLICY.md b/POLICY.md new file mode 100644 index 0000000..1fe7ad8 --- /dev/null +++ b/POLICY.md @@ -0,0 +1,514 @@ +# MCIAS Policy Engine + +Reference guide for the MCIAS attribute-based access control (ABAC) policy +engine. Covers concepts, rule authoring, the full action/resource catalogue, +built-in defaults, time-scoped rules, and worked examples. + +For the authoritative design rationale and middleware integration details see +[ARCHITECTURE.md §20](ARCHITECTURE.md). + +--- + +## 1. Concepts + +### Evaluation model + +The policy engine is a **pure function**: given a `PolicyInput` (assembled from +JWT claims and database lookups) and a slice of `Rule` values, it returns an +`Effect` (`allow` or `deny`) and a pointer to the matching rule. + +Evaluation proceeds in three steps: + +1. **Sort** all rules (built-in defaults + operator rules) by `Priority` + ascending. Lower number = evaluated first. Stable sort preserves insertion + order within the same priority. +2. **Deny-wins**: the first matching `deny` rule terminates evaluation + immediately and returns `Deny`. +3. **First-allow**: if no `deny` matched, the first matching `allow` rule + returns `Allow`. +4. **Default-deny**: if no rule matched at all, the request is denied. + +The engine never touches the database. The caller (middleware) is responsible +for assembling `PolicyInput` from JWT claims and DB lookups before calling +`engine.Evaluate`. + +### Rule matching + +A rule matches a request when **every populated field** satisfies its +condition. An empty/zero field is a wildcard (matches anything). + +| Rule field | Match condition | +|---|---| +| `roles` | Principal holds **at least one** of the listed roles | +| `account_types` | Principal's account type is in the list (`"human"`, `"system"`) | +| `subject_uuid` | Principal UUID equals this value exactly | +| `actions` | Request action is in the list | +| `resource_type` | Target resource type equals this value | +| `owner_matches_subject` | (if `true`) resource owner UUID equals the principal UUID | +| `service_names` | Target service account username is in the list | +| `required_tags` | Target account carries **all** of the listed tags | + +All conditions are AND-ed. To express OR across principals or resources, create +multiple rules. + +### Priority + +| Range | Intended use | +|---|---| +| 0 | Built-in defaults (compiled in; cannot be overridden via API) | +| 1–49 | High-precedence operator deny rules (explicit blocks) | +| 50–99 | Normal operator allow rules | +| 100 | Default for new rules created via API or CLI | +| 101+ | Low-precedence fallback rules | + +Because deny-wins applies within the matched set (not just within a priority +band), a `deny` rule at priority 100 still overrides an `allow` at priority 50 +if both match. Use explicit deny rules at low priority numbers (e.g. 10) when +you want them to fire before any allow can be considered. + +### Built-in default rules + +These rules are compiled into the binary (`internal/policy/defaults.go`). They +have IDs -1 through -7, priority 0, and **cannot be disabled or deleted via +the API**. They reproduce the previous binary admin/non-admin behavior exactly. + +| ID | Description | Conditions | Effect | +|---|---|---|---| +| -1 | Admin wildcard | `roles=[admin]` | allow | +| -2 | Self-service logout / token renewal | `actions=[auth:logout, tokens:renew]` | allow | +| -3 | Self-service TOTP enrollment | `actions=[totp:enroll]` | allow | +| -7 | Self-service password change | `account_types=[human]`, `actions=[auth:change_password]` | allow | +| -4 | System account reads own pgcreds | `account_types=[system]`, `actions=[pgcreds:read]`, `resource_type=pgcreds`, `owner_matches_subject=true` | allow | +| -5 | System account issues/renews own token | `account_types=[system]`, `actions=[tokens:issue, tokens:renew]`, `resource_type=token`, `owner_matches_subject=true` | allow | +| -6 | Public endpoints | `actions=[tokens:validate, auth:login]` | allow | + +Custom operator rules extend this baseline; they do not replace it. + +--- + +## 2. Actions and Resource Types + +### Actions + +Actions follow the `resource:verb` convention. Use the exact string values +shown below when authoring rules. + +| Action string | Description | Notes | +|---|---|---| +| `accounts:list` | List all accounts | admin | +| `accounts:create` | Create an account | admin | +| `accounts:read` | Read account details | admin | +| `accounts:update` | Update account (status, etc.) | admin | +| `accounts:delete` | Soft-delete an account | admin | +| `roles:read` | Read role assignments | admin | +| `roles:write` | Grant or revoke roles | admin | +| `tags:read` | Read account tags | admin | +| `tags:write` | Set account tags | admin | +| `tokens:issue` | Issue or rotate a service token | admin or delegate | +| `tokens:revoke` | Revoke a token | admin | +| `tokens:validate` | Validate a token | public | +| `tokens:renew` | Renew own token | self-service | +| `pgcreds:read` | Read Postgres credentials | admin or delegated | +| `pgcreds:write` | Set Postgres credentials | admin | +| `audit:read` | Read audit log | admin | +| `totp:enroll` | Enroll TOTP | self-service | +| `totp:remove` | Remove TOTP from an account | admin | +| `auth:login` | Authenticate (username + password) | public | +| `auth:logout` | Invalidate own session token | self-service | +| `auth:change_password` | Change own password | self-service | +| `policy:list` | List policy rules | admin | +| `policy:manage` | Create, update, or delete policy rules | admin | + +### Resource types + +| Resource type string | Description | +|---|---| +| `account` | A human or system account record | +| `token` | A JWT or service bearer token | +| `pgcreds` | A Postgres credential record | +| `audit_log` | The audit event log | +| `totp` | A TOTP enrollment record | +| `policy` | A policy rule record | + +--- + +## 3. Rule Schema + +Rules are stored in the `policy_rules` table. The `rule_json` column holds a +JSON-encoded `RuleBody`. All other fields are dedicated columns. + +### Database columns + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER PK | Auto-assigned | +| `priority` | INTEGER | Default 100; lower = evaluated first | +| `description` | TEXT | Human-readable label (required) | +| `enabled` | BOOLEAN | Disabled rules are excluded from the cache | +| `not_before` | DATETIME (nullable) | Rule inactive before this UTC timestamp | +| `expires_at` | DATETIME (nullable) | Rule inactive at and after this UTC timestamp | +| `rule_json` | TEXT | JSON-encoded `RuleBody` (see below) | + +### RuleBody JSON fields + +```json +{ + "effect": "allow" | "deny", + "roles": ["role1", "role2"], + "account_types": ["human"] | ["system"] | ["human", "system"], + "subject_uuid": "", + "actions": ["action:verb", ...], + "resource_type": "", + "owner_matches_subject": true | false, + "service_names": ["svc-username", ...], + "required_tags": ["tag:value", ...] +} +``` + +All fields are optional except `effect`. Omitted fields are wildcards. + +--- + +## 4. Managing Rules + +### Via mciasctl + +```sh +# List all rules +mciasctl policy list + +# Create a rule from a JSON file +mciasctl policy create -description "My rule" -json rule.json + +# Create a time-scoped rule +mciasctl policy create \ + -description "Temp production access" \ + -json rule.json \ + -not-before 2026-04-01T00:00:00Z \ + -expires-at 2026-04-01T04:00:00Z + +# Enable or disable a rule +mciasctl policy update -id 7 -enabled=false + +# Delete a rule +mciasctl policy delete -id 7 +``` + +### Via REST API (admin JWT required) + +| Method | Path | Description | +|---|---|---| +| GET | `/v1/policy/rules` | List all rules | +| POST | `/v1/policy/rules` | Create a rule | +| GET | `/v1/policy/rules/{id}` | Get a single rule | +| PATCH | `/v1/policy/rules/{id}` | Update priority, enabled, or description | +| DELETE | `/v1/policy/rules/{id}` | Delete a rule | + +### Via Web UI + +The `/policies` page lists all rules with enable/disable toggles and a create +form. Mutating operations use HTMX partial-page updates. + +### Cache reload + +The `Engine` caches the active rule set in memory. It reloads automatically +after any `policy_rule_*` admin event. To force a reload without a rule change, +send `SIGHUP` to `mciassrv`. + +--- + +## 5. Account Tags + +Tags are key:value strings attached to accounts (human or system) and used as +resource match conditions in rules. They are stored in the `account_tags` table. + +### Recommended tag conventions + +| Tag | Meaning | +|---|---| +| `env:production` | Account belongs to the production environment | +| `env:staging` | Account belongs to the staging environment | +| `env:dev` | Account belongs to the development environment | +| `svc:payments-api` | Account is associated with the payments-api service | +| `machine:db-west-01` | Account is associated with a specific host | +| `team:platform` | Account is owned by the platform team | + +Tag names are not enforced by the schema; the conventions above are +recommendations only. + +### Managing tags + +```sh +# Set tags on an account (replaces the full tag set atomically) +mciasctl accounts update -id -tags "env:staging,svc:payments-api" + +# Via REST (admin JWT) +PUT /v1/accounts/{id}/tags +Content-Type: application/json +["env:staging", "svc:payments-api"] +``` + +--- + +## 6. Worked Examples + +### Example A — Named service delegation + +**Goal:** Alice needs to read Postgres credentials for `payments-api` only. + +1. Grant Alice the role `svc:payments-api`: + + ```sh + mciasctl accounts roles grant -id -role svc:payments-api + ``` + +2. Create the allow rule (`rule.json`): + + ```json + { + "effect": "allow", + "roles": ["svc:payments-api"], + "actions": ["pgcreds:read"], + "resource_type": "pgcreds", + "service_names": ["payments-api"] + } + ``` + + ```sh + mciasctl policy create -description "Alice: read payments-api pgcreds" \ + -json rule.json -priority 50 + ``` + +When Alice calls `GET /v1/accounts/{payments-api-uuid}/pgcreds`, the middleware +sets `resource.ServiceName = "payments-api"`. The rule matches and access is +granted. A call against any other service account sets a different +`ServiceName`; no rule matches and default-deny applies. + +--- + +### Example B — Machine-tag gating (staging only) + +**Goal:** `deploy-agent` may read pgcreds for staging accounts but must be +explicitly blocked from production. + +1. Tag all staging system accounts: + + ```sh + mciasctl accounts update -id -tags "env:staging" + ``` + +2. Explicit deny for production (low priority number = evaluated first): + + ```json + { + "effect": "deny", + "subject_uuid": "", + "resource_type": "pgcreds", + "required_tags": ["env:production"] + } + ``` + + ```sh + mciasctl policy create -description "deploy-agent: deny production pgcreds" \ + -json deny.json -priority 10 + ``` + +3. Allow for staging: + + ```json + { + "effect": "allow", + "subject_uuid": "", + "actions": ["pgcreds:read"], + "resource_type": "pgcreds", + "required_tags": ["env:staging"] + } + ``` + + ```sh + mciasctl policy create -description "deploy-agent: allow staging pgcreds" \ + -json allow.json -priority 50 + ``` + +The deny rule (priority 10) fires before the allow rule (priority 50) for any +production-tagged resource. For staging resources the deny does not match and +the allow rule permits access. + +--- + +### Example C — Blanket "secrets reader" role + +**Goal:** Any account holding the `secrets-reader` role may read pgcreds for +any service. + +```json +{ + "effect": "allow", + "roles": ["secrets-reader"], + "actions": ["pgcreds:read"], + "resource_type": "pgcreds" +} +``` + +```sh +mciasctl policy create -description "secrets-reader: read any pgcreds" \ + -json rule.json -priority 50 +``` + +No `service_names` or `required_tags` means the rule matches any target +account. Grant the role to any account that needs broad read access: + +```sh +mciasctl accounts roles grant -id -role secrets-reader +``` + +--- + +### Example D — Time-scoped emergency access + +**Goal:** `deploy-agent` needs temporary access to production pgcreds for a +4-hour maintenance window on 2026-04-01. + +```json +{ + "effect": "allow", + "subject_uuid": "", + "actions": ["pgcreds:read"], + "resource_type": "pgcreds", + "required_tags": ["env:production"] +} +``` + +```sh +mciasctl policy create \ + -description "deploy-agent: temp production access (maintenance window)" \ + -json rule.json \ + -priority 50 \ + -not-before 2026-04-01T02:00:00Z \ + -expires-at 2026-04-01T06:00:00Z +``` + +The engine excludes this rule from the cache before `not_before` and after +`expires_at`. No manual cleanup is required; the rule becomes inert +automatically. Both fields are nullable — omitting either means no constraint +on that end. + +--- + +### Example E — Per-account subject rule + +**Goal:** Bob (a contractor) may issue/rotate the token for `worker-bot` only, +without any admin role. + +1. Grant delegation via the delegation API (preferred for token issuance; see + ARCHITECTURE.md §21): + + ```sh + mciasctl accounts token delegates grant \ + -id -grantee + ``` + + Or, equivalently, via a policy rule: + + ```json + { + "effect": "allow", + "subject_uuid": "", + "actions": ["tokens:issue", "tokens:renew"], + "resource_type": "token", + "service_names": ["worker-bot"] + } + ``` + + ```sh + mciasctl policy create -description "Bob: issue worker-bot token" \ + -json rule.json -priority 50 + ``` + +2. Bob uses the `/service-accounts` UI page or `mciasctl` to rotate the token + and download it via the one-time nonce endpoint. + +--- + +### Example F — Deny a specific account from all access + +**Goal:** Temporarily block `mallory` (UUID known) from all operations without +deleting the account. + +```json +{ + "effect": "deny", + "subject_uuid": "" +} +``` + +```sh +mciasctl policy create -description "Block mallory (incident response)" \ + -json rule.json -priority 1 +``` + +Priority 1 ensures this deny fires before any allow rule. Because deny-wins +applies globally (not just within a priority band), this blocks mallory even +though the admin wildcard (priority 0, allow) would otherwise match. Note: the +admin wildcard is an `allow` rule; a `deny` at any priority overrides it for +the matched principal. + +To lift the block, delete or disable the rule: + +```sh +mciasctl policy update -id -enabled=false +``` + +--- + +## 7. Security Recommendations + +1. **Prefer explicit deny rules for sensitive resources.** Use `required_tags` + or `service_names` to scope allow rules narrowly, and add a corresponding + deny rule at a lower priority number for the resources that must never be + accessible. + +2. **Use time-scoped rules for temporary access.** Set `expires_at` instead of + creating a rule and relying on manual deletion. The engine enforces expiry + automatically at cache-load time. + +3. **Avoid wildcard allow rules without resource scoping.** A rule with only + `roles` and `actions` but no `resource_type`, `service_names`, or + `required_tags` matches every resource of every type. Scope rules as + narrowly as the use case allows. + +4. **Audit deny events.** Every explicit deny produces a `policy_deny` audit + event. Review the audit log (`GET /v1/audit` or the `/audit` UI page) + regularly to detect unexpected access patterns. + +5. **Do not rely on priority alone for security boundaries.** Priority controls + evaluation order, not security strength. A deny rule at priority 100 still + overrides an allow at priority 50 if both match. Use deny rules explicitly + rather than assuming a lower-priority allow will be shadowed. + +6. **Keep the built-in defaults intact.** The compiled-in rules reproduce the + baseline admin/self-service behavior. Custom rules extend this baseline; + they cannot disable the defaults. Do not attempt to work around them by + creating conflicting operator rules — the deny-wins semantics mean an + operator deny at priority 1 will block even the admin wildcard for the + matched principal. + +7. **Reload after bulk changes.** After importing many rules via the REST API, + send `SIGHUP` to `mciassrv` to force an immediate cache reload rather than + waiting for the next individual rule event. + +--- + +## 8. Audit Events + +| Event | Trigger | +|---|---| +| `policy_deny` | Engine denied a request; payload: `{action, resource_type, service_name, required_tags, matched_rule_id}` — never contains credential material | +| `policy_rule_created` | New operator rule created | +| `policy_rule_updated` | Rule priority, enabled flag, or description changed | +| `policy_rule_deleted` | Rule deleted | +| `tag_added` | Tag added to an account | +| `tag_removed` | Tag removed from an account | + +All events are written to the `audit_events` table and are visible via +`GET /v1/audit` (admin JWT required) or the `/audit` web UI page. diff --git a/PROGRESS.md b/PROGRESS.md index 9e8df2f..151ff5c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,6 +4,34 @@ Source of truth for current development state. --- All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only). +### 2026-03-15 — Checkpoint: lint fixes + +**Task:** Checkpoint — lint clean, tests pass, commit. + +**Lint fixes (13 issues resolved):** +- `errorlint`: `internal/vault/vault_test.go` — replaced `err != ErrSealed` with `errors.Is(err, ErrSealed)`. +- `gofmt`: `internal/config/config.go`, `internal/config/config_test.go`, `internal/middleware/middleware_test.go` — reformatted with `goimports`. +- `govet/fieldalignment`: `internal/vault/vault.go`, `internal/ui/csrf.go`, `internal/audit/detail_test.go`, `internal/middleware/middleware_test.go` — reordered struct fields for optimal alignment. +- `unused`: `internal/ui/csrf.go` — removed unused `newCSRFManager` function (superseded by `newCSRFManagerFromVault`). +- `revive/early-return`: `cmd/mciassrv/main.go` — inverted condition to eliminate else-after-return. + +**Verification:** `golangci-lint run ./...` → 0 issues; `go test ./...` → all packages pass. + +--- + +### 2026-03-15 — Documentation: ARCHITECTURE.md update + POLICY.md + +**Task:** Ensure ARCHITECTURE.md is accurate; add POLICY.md describing the policy engine. + +**ARCHITECTURE.md fix:** +- Corrected `Rule.ID` comment: built-in default rules use negative IDs (-1 … -7), not 0 (§20 Core Types code block). + +**New file: POLICY.md** +- Operator reference guide for the ABAC policy engine. +- Covers: evaluation model (deny-wins, default-deny, stable priority sort), rule matching semantics, priority conventions, all built-in default rules (IDs -1 … -7) with conditions, full action and resource-type catalogue, rule schema (DB columns + RuleBody JSON), rule management via `mciasctl` / REST API / Web UI, account tag conventions, cache reload, six worked examples (named service delegation, machine-tag gating, blanket role, time-scoped access, per-account subject rule, incident-response deny), security recommendations, and audit events. + +--- + ### 2026-03-15 — Service account token delegation and download **Problem:** Only admins could issue tokens for service accounts, and the only way to retrieve the token was a flash message (copy-paste). There was no delegation mechanism for non-admin users. diff --git a/cmd/mciassrv/main.go b/cmd/mciassrv/main.go index 2bbe89e..d9e090a 100644 --- a/cmd/mciassrv/main.go +++ b/cmd/mciassrv/main.go @@ -87,17 +87,16 @@ func run(configPath string, logger *slog.Logger) error { masterKey, mkErr := loadMasterKey(cfg, database) if mkErr != nil { // Check if we can start sealed (passphrase mode, empty env var). - if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" { - // Verify that this is not a first run — the signing key must already exist. - enc, nonce, scErr := database.ReadServerConfig() - if scErr != nil || enc == nil || nonce == nil { - return fmt.Errorf("first run requires passphrase: %w", mkErr) - } - v = vault.NewSealed() - logger.Info("vault starting in sealed state") - } else { + if cfg.MasterKey.KeyFile != "" || os.Getenv(cfg.MasterKey.PassphraseEnv) != "" { return fmt.Errorf("load master key: %w", mkErr) } + // Verify that this is not a first run — the signing key must already exist. + enc, nonce, scErr := database.ReadServerConfig() + if scErr != nil || enc == nil || nonce == nil { + return fmt.Errorf("first run requires passphrase: %w", mkErr) + } + v = vault.NewSealed() + logger.Info("vault starting in sealed state") } else { // Load or generate the Ed25519 signing key. // Security: The private signing key is stored AES-256-GCM encrypted in the diff --git a/internal/audit/detail_test.go b/internal/audit/detail_test.go index 3afa5fd..1dbe6e9 100644 --- a/internal/audit/detail_test.go +++ b/internal/audit/detail_test.go @@ -7,9 +7,9 @@ import ( func TestJSON(t *testing.T) { tests := []struct { + verify func(t *testing.T, result string) name string pairs []string - verify func(t *testing.T, result string) }{ { name: "single pair", @@ -109,9 +109,9 @@ func TestJSON(t *testing.T) { func TestJSONWithRoles(t *testing.T) { tests := []struct { + verify func(t *testing.T, result string) name string roles []string - verify func(t *testing.T, result string) }{ { name: "multiple roles", diff --git a/internal/config/config.go b/internal/config/config.go index c464a5a..1cb2712 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -174,8 +174,8 @@ func (c *Config) validate() error { // generous to accommodate a range of legitimate deployments while // catching obvious typos (e.g. "876000h" instead of "8760h"). const ( - maxDefaultExpiry = 30 * 24 * time.Hour // 30 days - maxAdminExpiry = 24 * time.Hour // 24 hours + maxDefaultExpiry = 30 * 24 * time.Hour // 30 days + maxAdminExpiry = 24 * time.Hour // 24 hours maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years ) if c.Tokens.DefaultExpiry.Duration <= 0 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a3a622a..611f471 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -213,9 +213,9 @@ threads = 4 // TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP. func TestTrustedProxyValidation(t *testing.T) { tests := []struct { - name string - proxy string - wantErr bool + name string + proxy string + wantErr bool }{ {"empty is valid (disabled)", "", false}, {"valid IPv4", "127.0.0.1", false}, diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 95412b4..b0503e4 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -361,8 +361,8 @@ func TestClientIP(t *testing.T) { remoteAddr string xForwardedFor string xRealIP string - trustedProxy net.IP want string + trustedProxy net.IP }{ { name: "no proxy configured: uses RemoteAddr", @@ -377,11 +377,11 @@ func TestClientIP(t *testing.T) { want: "198.51.100.9", }, { - name: "request from trusted proxy with X-Real-IP: uses X-Real-IP", - remoteAddr: "10.0.0.1:8080", - xRealIP: "203.0.113.42", - trustedProxy: proxy, - want: "203.0.113.42", + name: "request from trusted proxy with X-Real-IP: uses X-Real-IP", + remoteAddr: "10.0.0.1:8080", + xRealIP: "203.0.113.42", + trustedProxy: proxy, + want: "203.0.113.42", }, { name: "request from trusted proxy with X-Forwarded-For: uses first entry", @@ -407,10 +407,10 @@ func TestClientIP(t *testing.T) { want: "203.0.113.55", }, { - name: "proxy request with no forwarding headers falls back to RemoteAddr host", - remoteAddr: "10.0.0.1:8080", - trustedProxy: proxy, - want: "10.0.0.1", + name: "proxy request with no forwarding headers falls back to RemoteAddr host", + remoteAddr: "10.0.0.1:8080", + trustedProxy: proxy, + want: "10.0.0.1", }, { // Security: attacker fakes X-Forwarded-For but connects directly. diff --git a/internal/ui/csrf.go b/internal/ui/csrf.go index f50c162..f7e2cf1 100644 --- a/internal/ui/csrf.go +++ b/internal/ui/csrf.go @@ -29,15 +29,9 @@ import ( // on the next unseal. This is safe because sealed middleware prevents // reaching CSRF-protected routes. type CSRFManager struct { - mu sync.Mutex - key []byte vault *vault.Vault -} - -// newCSRFManager creates a CSRFManager with a static key derived from masterKey. -// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey) -func newCSRFManager(masterKey []byte) *CSRFManager { - return &CSRFManager{key: deriveCSRFKey(masterKey)} + key []byte + mu sync.Mutex } // newCSRFManagerFromVault creates a CSRFManager that derives its key lazily diff --git a/internal/vault/vault.go b/internal/vault/vault.go index c0195a9..3b100a8 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -24,10 +24,10 @@ var ErrSealed = errors.New("vault is sealed") // Vault holds the server's cryptographic key material behind a mutex. // All three servers (REST, UI, gRPC) share a single Vault by pointer. type Vault struct { - mu sync.RWMutex masterKey []byte privKey ed25519.PrivateKey pubKey ed25519.PublicKey + mu sync.RWMutex sealed bool } diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 03275e7..bca96d3 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -3,6 +3,7 @@ package vault import ( "crypto/ed25519" "crypto/rand" + "errors" "sync" "testing" ) @@ -25,13 +26,13 @@ func TestNewSealed(t *testing.T) { if !v.IsSealed() { t.Fatal("NewSealed() should be sealed") } - if _, err := v.MasterKey(); err != ErrSealed { + if _, err := v.MasterKey(); !errors.Is(err, ErrSealed) { t.Fatalf("MasterKey() error = %v, want ErrSealed", err) } - if _, err := v.PrivKey(); err != ErrSealed { + if _, err := v.PrivKey(); !errors.Is(err, ErrSealed) { t.Fatalf("PrivKey() error = %v, want ErrSealed", err) } - if _, err := v.PubKey(); err != ErrSealed { + if _, err := v.PubKey(); !errors.Is(err, ErrSealed) { t.Fatalf("PubKey() error = %v, want ErrSealed", err) } }