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:
@@ -1 +1 @@
|
|||||||
[{"lang":"en","usageCount":1}]
|
[{"lang":"en","usageCount":3}]
|
||||||
135
ARCHITECTURE.md
135
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 |
|
| POST | `/v1/token/issue` | admin JWT | Issue service account token |
|
||||||
| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI |
|
| 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 |
|
| 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 |
|
| GET | `/v1/accounts/{id}` | admin JWT | Get account details |
|
||||||
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
||||||
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
|
| 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
|
### Password Endpoints
|
||||||
|
|
||||||
@@ -479,6 +498,7 @@ cookie pattern (`mcias_csrf`).
|
|||||||
| `/policies` | Policy rules management — create, enable/disable, delete |
|
| `/policies` | Policy rules management — create, enable/disable, delete |
|
||||||
| `/audit` | Audit log viewer |
|
| `/audit` | Audit log viewer |
|
||||||
| `/profile` | User profile — self-service password change (any authenticated user) |
|
| `/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
|
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
||||||
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
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)
|
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
|
||||||
expires_at TEXT DEFAULT NULL -- optional: expiry 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
|
### 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_updated` | Policy rule updated (priority, enabled, description) |
|
||||||
| `policy_rule_deleted` | Policy rule deleted |
|
| `policy_rule_deleted` | Policy rule deleted |
|
||||||
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
|
| `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_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` |
|
| `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
|
- A human account should be able to access credentials for one specific service
|
||||||
without being a full admin.
|
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
|
- A system account (`deploy-agent`) should only operate on hosts tagged
|
||||||
`env:staging`, not `env:production`.
|
`env:staging`, not `env:production`.
|
||||||
- A "secrets reader" role should read pgcreds for any service but change nothing.
|
- 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.
|
// Rule is a single policy statement. All populated fields are ANDed.
|
||||||
// A zero/empty field is a wildcard (matches anything).
|
// A zero/empty field is a wildcard (matches anything).
|
||||||
type Rule struct {
|
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
|
Description string
|
||||||
|
|
||||||
// Principal match conditions
|
// Principal match conditions
|
||||||
@@ -1681,3 +1721,94 @@ introduced.
|
|||||||
| `policy_rule_deleted` | Rule deleted |
|
| `policy_rule_deleted` | Rule deleted |
|
||||||
| `tag_added` | Tag added to an account |
|
| `tag_added` | Tag added to an account |
|
||||||
| `tag_removed` | Tag removed from 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
514
POLICY.md
Normal 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) |
|
||||||
|
| 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": "<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.
|
||||||
28
PROGRESS.md
28
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).
|
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
|
### 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.
|
**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.
|
||||||
|
|||||||
@@ -87,17 +87,16 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
masterKey, mkErr := loadMasterKey(cfg, database)
|
masterKey, mkErr := loadMasterKey(cfg, database)
|
||||||
if mkErr != nil {
|
if mkErr != nil {
|
||||||
// Check if we can start sealed (passphrase mode, empty env var).
|
// Check if we can start sealed (passphrase mode, empty env var).
|
||||||
if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" {
|
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 {
|
|
||||||
return fmt.Errorf("load master key: %w", mkErr)
|
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 {
|
} else {
|
||||||
// Load or generate the Ed25519 signing key.
|
// Load or generate the Ed25519 signing key.
|
||||||
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
|
|
||||||
func TestJSON(t *testing.T) {
|
func TestJSON(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
verify func(t *testing.T, result string)
|
||||||
name string
|
name string
|
||||||
pairs []string
|
pairs []string
|
||||||
verify func(t *testing.T, result string)
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "single pair",
|
name: "single pair",
|
||||||
@@ -109,9 +109,9 @@ func TestJSON(t *testing.T) {
|
|||||||
|
|
||||||
func TestJSONWithRoles(t *testing.T) {
|
func TestJSONWithRoles(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
verify func(t *testing.T, result string)
|
||||||
name string
|
name string
|
||||||
roles []string
|
roles []string
|
||||||
verify func(t *testing.T, result string)
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "multiple roles",
|
name: "multiple roles",
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ func (c *Config) validate() error {
|
|||||||
// generous to accommodate a range of legitimate deployments while
|
// generous to accommodate a range of legitimate deployments while
|
||||||
// catching obvious typos (e.g. "876000h" instead of "8760h").
|
// catching obvious typos (e.g. "876000h" instead of "8760h").
|
||||||
const (
|
const (
|
||||||
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
|
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
|
||||||
maxAdminExpiry = 24 * time.Hour // 24 hours
|
maxAdminExpiry = 24 * time.Hour // 24 hours
|
||||||
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
|
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
|
||||||
)
|
)
|
||||||
if c.Tokens.DefaultExpiry.Duration <= 0 {
|
if c.Tokens.DefaultExpiry.Duration <= 0 {
|
||||||
|
|||||||
@@ -213,9 +213,9 @@ threads = 4
|
|||||||
// TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP.
|
// TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP.
|
||||||
func TestTrustedProxyValidation(t *testing.T) {
|
func TestTrustedProxyValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
proxy string
|
proxy string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{"empty is valid (disabled)", "", false},
|
{"empty is valid (disabled)", "", false},
|
||||||
{"valid IPv4", "127.0.0.1", false},
|
{"valid IPv4", "127.0.0.1", false},
|
||||||
|
|||||||
@@ -361,8 +361,8 @@ func TestClientIP(t *testing.T) {
|
|||||||
remoteAddr string
|
remoteAddr string
|
||||||
xForwardedFor string
|
xForwardedFor string
|
||||||
xRealIP string
|
xRealIP string
|
||||||
trustedProxy net.IP
|
|
||||||
want string
|
want string
|
||||||
|
trustedProxy net.IP
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no proxy configured: uses RemoteAddr",
|
name: "no proxy configured: uses RemoteAddr",
|
||||||
@@ -377,11 +377,11 @@ func TestClientIP(t *testing.T) {
|
|||||||
want: "198.51.100.9",
|
want: "198.51.100.9",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
|
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
|
||||||
remoteAddr: "10.0.0.1:8080",
|
remoteAddr: "10.0.0.1:8080",
|
||||||
xRealIP: "203.0.113.42",
|
xRealIP: "203.0.113.42",
|
||||||
trustedProxy: proxy,
|
trustedProxy: proxy,
|
||||||
want: "203.0.113.42",
|
want: "203.0.113.42",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "request from trusted proxy with X-Forwarded-For: uses first entry",
|
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",
|
want: "203.0.113.55",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
|
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
|
||||||
remoteAddr: "10.0.0.1:8080",
|
remoteAddr: "10.0.0.1:8080",
|
||||||
trustedProxy: proxy,
|
trustedProxy: proxy,
|
||||||
want: "10.0.0.1",
|
want: "10.0.0.1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Security: attacker fakes X-Forwarded-For but connects directly.
|
// Security: attacker fakes X-Forwarded-For but connects directly.
|
||||||
|
|||||||
@@ -29,15 +29,9 @@ import (
|
|||||||
// on the next unseal. This is safe because sealed middleware prevents
|
// on the next unseal. This is safe because sealed middleware prevents
|
||||||
// reaching CSRF-protected routes.
|
// reaching CSRF-protected routes.
|
||||||
type CSRFManager struct {
|
type CSRFManager struct {
|
||||||
mu sync.Mutex
|
|
||||||
key []byte
|
|
||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
}
|
key []byte
|
||||||
|
mu sync.Mutex
|
||||||
// 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)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ var ErrSealed = errors.New("vault is sealed")
|
|||||||
// Vault holds the server's cryptographic key material behind a mutex.
|
// Vault holds the server's cryptographic key material behind a mutex.
|
||||||
// All three servers (REST, UI, gRPC) share a single Vault by pointer.
|
// All three servers (REST, UI, gRPC) share a single Vault by pointer.
|
||||||
type Vault struct {
|
type Vault struct {
|
||||||
mu sync.RWMutex
|
|
||||||
masterKey []byte
|
masterKey []byte
|
||||||
privKey ed25519.PrivateKey
|
privKey ed25519.PrivateKey
|
||||||
pubKey ed25519.PublicKey
|
pubKey ed25519.PublicKey
|
||||||
|
mu sync.RWMutex
|
||||||
sealed bool
|
sealed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -25,13 +26,13 @@ func TestNewSealed(t *testing.T) {
|
|||||||
if !v.IsSealed() {
|
if !v.IsSealed() {
|
||||||
t.Fatal("NewSealed() should be sealed")
|
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)
|
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)
|
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)
|
t.Fatalf("PubKey() error = %v, want ErrSealed", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user