Checkpoint: fix all lint warnings

- errorlint: use errors.Is for ErrSealed comparisons in vault_test.go
- gofmt: reformat config, config_test, middleware_test with goimports
- govet/fieldalignment: reorder struct fields in vault.go, csrf.go,
  detail_test.go, middleware_test.go for optimal alignment
- unused: remove unused newCSRFManager in csrf.go (superseded by
  newCSRFManagerFromVault)
- revive/early-return: invert sealed-vault condition in main.go

Security: no auth/crypto logic changed; struct reordering and error
comparison fixes only. newCSRFManager removal is safe — it was never
called; all CSRF construction goes through newCSRFManagerFromVault.

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-03-15 16:40:11 -07:00
parent 9657f18784
commit cb661bb8f5
12 changed files with 708 additions and 41 deletions

View File

@@ -1 +1 @@
[{"lang":"en","usageCount":1}]
[{"lang":"en","usageCount":3}]

View File

@@ -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) |

514
POLICY.md Normal file
View File

@@ -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) |
| 149 | High-precedence operator deny rules (explicit blocks) |
| 5099 | 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": "<UUID string>",
"actions": ["action:verb", ...],
"resource_type": "<resource type string>",
"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 <uuid> -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 <alice-uuid> -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 <svc-uuid> -tags "env:staging"
```
2. Explicit deny for production (low priority number = evaluated first):
```json
{
"effect": "deny",
"subject_uuid": "<deploy-agent-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": "<deploy-agent-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 <uuid> -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": "<deploy-agent-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 <worker-bot-uuid> -grantee <bob-uuid>
```
Or, equivalently, via a policy rule:
```json
{
"effect": "allow",
"subject_uuid": "<bob-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": "<mallory-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 <rule-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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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},

View File

@@ -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.

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}