Add PG creds + policy/tags UI; fix lint and build
- internal/ui/ui.go: add PGCred, Tags to AccountDetailData; register
PUT /accounts/{id}/pgcreds and PUT /accounts/{id}/tags routes; add
pgcreds_form.html and tags_editor.html to shared template set; remove
unused AccountTagsData; fix fieldalignment on PolicyRuleView, PoliciesData
- internal/ui/handlers_accounts.go: add handleSetPGCreds — encrypts
password via crypto.SealAESGCM, writes audit EventPGCredUpdated, renders
pgcreds_form fragment; password never echoed; load PG creds and tags in
handleAccountDetail
- internal/ui/handlers_policy.go: fix handleSetAccountTags to render with
AccountDetailData instead of removed AccountTagsData
- internal/ui/ui_test.go: add 5 PG credential UI tests
- web/templates/fragments/pgcreds_form.html: new fragment — metadata display
+ set/replace form; system accounts only; password write-only
- web/templates/fragments/tags_editor.html: new fragment — textarea editor
with HTMX PUT for atomic tag replacement
- web/templates/fragments/policy_form.html: rewrite to use structured fields
matching handleCreatePolicyRule (roles/account_types/actions multi-select,
resource_type, subject_uuid, service_names, required_tags, checkbox)
- web/templates/policies.html: new policies management page
- web/templates/fragments/policy_row.html: new HTMX table row with toggle
and delete
- web/templates/account_detail.html: add Tags card and PG Credentials card
- web/templates/base.html: add Policies nav link
- internal/server/server.go: remove ~220 lines of duplicate tag/policy
handler code (real implementations are in handlers_policy.go)
- internal/policy/engine_wrapper.go: fix corrupted source; use errors.New
- internal/db/policy_test.go: use model.AccountTypeHuman constant
- cmd/mciasctl/main.go: add nolint:gosec to int(os.Stdin.Fd()) calls
- gofmt/goimports: db/policy_test.go, policy/defaults.go,
policy/engine_test.go, ui/ui.go, cmd/mciasctl/main.go
- fieldalignment: model.PolicyRuleRecord, policy.Engine, policy.Rule,
policy.RuleBody, ui.PolicyRuleView
Security: PG password encrypted AES-256-GCM with fresh random nonce before
storage; plaintext never logged or returned in any response; audit event
written on every credential write.
This commit is contained in:
366
ARCHITECTURE.md
366
ARCHITECTURE.md
@@ -137,6 +137,19 @@ Reserved roles:
|
|||||||
|
|
||||||
Role assignment requires admin privileges.
|
Role assignment requires admin privileges.
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Accounts (both human and system) may carry zero or more string tags stored in
|
||||||
|
the `account_tags` table. Tags are used by the policy engine to match resource
|
||||||
|
access rules against machine or service identity.
|
||||||
|
|
||||||
|
Tag naming convention (not enforced by the schema, but recommended):
|
||||||
|
- `env:production`, `env:staging` — environment tier
|
||||||
|
- `svc:payments-api` — named service association
|
||||||
|
- `machine:db-west-01` — specific host label
|
||||||
|
|
||||||
|
Tag management requires admin privileges.
|
||||||
|
|
||||||
### Account Lifecycle
|
### Account Lifecycle
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -313,6 +326,23 @@ All endpoints use JSON request/response bodies. All responses include a
|
|||||||
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials |
|
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials |
|
||||||
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
|
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
|
||||||
|
|
||||||
|
### Tag Endpoints (admin only)
|
||||||
|
|
||||||
|
| Method | Path | Auth required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/v1/accounts/{id}/tags` | admin JWT | List tags for account |
|
||||||
|
| PUT | `/v1/accounts/{id}/tags` | admin JWT | Replace tag set for account |
|
||||||
|
|
||||||
|
### Policy Endpoints (admin only)
|
||||||
|
|
||||||
|
| Method | Path | Auth required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/v1/policy/rules` | admin JWT | List all policy rules |
|
||||||
|
| POST | `/v1/policy/rules` | admin JWT | Create a new policy rule |
|
||||||
|
| GET | `/v1/policy/rules/{id}` | admin JWT | Get a single policy rule |
|
||||||
|
| PATCH | `/v1/policy/rules/{id}` | admin JWT | Update rule (priority, enabled, description) |
|
||||||
|
| DELETE | `/v1/policy/rules/{id}` | admin JWT | Delete a policy rule |
|
||||||
|
|
||||||
### Audit Endpoints (admin only)
|
### Audit Endpoints (admin only)
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
@@ -443,6 +473,31 @@ CREATE TABLE audit_log (
|
|||||||
CREATE INDEX idx_audit_time ON audit_log (event_time);
|
CREATE INDEX idx_audit_time ON audit_log (event_time);
|
||||||
CREATE INDEX idx_audit_actor ON audit_log (actor_id);
|
CREATE INDEX idx_audit_actor ON audit_log (actor_id);
|
||||||
CREATE INDEX idx_audit_event ON audit_log (event_type);
|
CREATE INDEX idx_audit_event ON audit_log (event_type);
|
||||||
|
|
||||||
|
-- Machine/service tags on accounts (many-to-many).
|
||||||
|
-- Used by the policy engine for resource gating (e.g. env:production, svc:payments-api).
|
||||||
|
CREATE TABLE account_tags (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
PRIMARY KEY (account_id, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_account_tags_account ON account_tags (account_id);
|
||||||
|
|
||||||
|
-- Policy rules stored in the database and evaluated in-process.
|
||||||
|
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
|
||||||
|
-- Built-in default rules are compiled into the binary and are not stored here.
|
||||||
|
CREATE TABLE policy_rules (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100, -- lower value = evaluated first
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
rule_json TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
|
||||||
|
created_by INTEGER REFERENCES accounts(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Schema Notes
|
### Schema Notes
|
||||||
@@ -527,8 +582,9 @@ mcias/
|
|||||||
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
||||||
│ ├── db/ # SQLite access layer (schema, migrations, queries)
|
│ ├── db/ # SQLite access layer (schema, migrations, queries)
|
||||||
│ ├── grpcserver/ # gRPC handler implementations (Phase 7)
|
│ ├── grpcserver/ # gRPC handler implementations (Phase 7)
|
||||||
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit)
|
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
|
||||||
│ ├── model/ # shared data types (Account, Token, Role, etc.)
|
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
|
||||||
|
│ ├── policy/ # in-process authorization policy engine (§20)
|
||||||
│ ├── server/ # HTTP handlers, router setup
|
│ ├── server/ # HTTP handlers, router setup
|
||||||
│ ├── token/ # JWT issuance, validation, revocation
|
│ ├── token/ # JWT issuance, validation, revocation
|
||||||
│ └── ui/ # web UI context, CSRF, session, template handlers
|
│ └── ui/ # web UI context, CSRF, session, template handlers
|
||||||
@@ -581,6 +637,12 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
|
|||||||
| `totp_removed` | TOTP removed from account |
|
| `totp_removed` | TOTP removed from account |
|
||||||
| `pgcred_accessed` | Postgres credentials retrieved |
|
| `pgcred_accessed` | Postgres credentials retrieved |
|
||||||
| `pgcred_updated` | Postgres credentials stored/updated |
|
| `pgcred_updated` | Postgres credentials stored/updated |
|
||||||
|
| `tag_added` | Tag added to account |
|
||||||
|
| `tag_removed` | Tag removed from account |
|
||||||
|
| `policy_rule_created` | Policy rule created |
|
||||||
|
| `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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1123,3 +1185,303 @@ Each other language library includes its own inline mock:
|
|||||||
`tests/mock-server.lisp`; started on a random port per test via
|
`tests/mock-server.lisp`; started on a random port per test via
|
||||||
`start-mock-server` / `stop-mock-server`
|
`start-mock-server` / `stop-mock-server`
|
||||||
- **Python**: `respx` mock transport for `httpx`; `@respx.mock` decorator
|
- **Python**: `respx` mock transport for `httpx`; `@respx.mock` decorator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Authorization Policy Engine
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
The initial authorization model is binary: the `admin` role grants full access;
|
||||||
|
all other authenticated principals have access only to self-service operations
|
||||||
|
(logout, token renewal, TOTP enrollment). As MCIAS manages credentials for
|
||||||
|
multiple personal applications running on multiple machines, a richer model is
|
||||||
|
needed:
|
||||||
|
|
||||||
|
- A human account should be able to access credentials for one specific service
|
||||||
|
without being a full admin.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
The policy engine adds fine-grained, attribute-based access control (ABAC) as
|
||||||
|
an in-process Go package (`internal/policy`) with no external dependencies.
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
- **Deny-wins**: any explicit `deny` rule overrides all `allow` rules.
|
||||||
|
- **Default-deny**: if no rule matches, the request is denied.
|
||||||
|
- **Compiled-in defaults**: a set of built-in rules encoded in Go reproduces
|
||||||
|
the previous binary behavior exactly. They cannot be disabled via the API.
|
||||||
|
- **Pure evaluation**: `Evaluate()` is a stateless function; it takes a
|
||||||
|
`PolicyInput` and a slice of `Rule` values and returns an effect. The caller
|
||||||
|
assembles the input from JWT claims and DB lookups; the engine never touches
|
||||||
|
the database.
|
||||||
|
- **Auditable**: every explicit `deny` produces a `policy_deny` audit event
|
||||||
|
recording which rule matched. Every `allow` on a sensitive resource (pgcreds,
|
||||||
|
token issuance) is also logged.
|
||||||
|
|
||||||
|
### Core Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
// package internal/policy
|
||||||
|
|
||||||
|
type Action string
|
||||||
|
type ResourceType string
|
||||||
|
type Effect string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Actions
|
||||||
|
ActionListAccounts Action = "accounts:list"
|
||||||
|
ActionCreateAccount Action = "accounts:create"
|
||||||
|
ActionReadAccount Action = "accounts:read"
|
||||||
|
ActionUpdateAccount Action = "accounts:update"
|
||||||
|
ActionDeleteAccount Action = "accounts:delete"
|
||||||
|
ActionReadRoles Action = "roles:read"
|
||||||
|
ActionWriteRoles Action = "roles:write"
|
||||||
|
ActionReadTags Action = "tags:read"
|
||||||
|
ActionWriteTags Action = "tags:write"
|
||||||
|
ActionIssueToken Action = "tokens:issue"
|
||||||
|
ActionRevokeToken Action = "tokens:revoke"
|
||||||
|
ActionValidateToken Action = "tokens:validate" // public
|
||||||
|
ActionRenewToken Action = "tokens:renew" // self-service
|
||||||
|
ActionReadPGCreds Action = "pgcreds:read"
|
||||||
|
ActionWritePGCreds Action = "pgcreds:write"
|
||||||
|
ActionReadAudit Action = "audit:read"
|
||||||
|
ActionEnrollTOTP Action = "totp:enroll" // self-service
|
||||||
|
ActionRemoveTOTP Action = "totp:remove" // admin
|
||||||
|
ActionLogin Action = "auth:login" // public
|
||||||
|
ActionLogout Action = "auth:logout" // self-service
|
||||||
|
ActionListRules Action = "policy:list"
|
||||||
|
ActionManageRules Action = "policy:manage"
|
||||||
|
|
||||||
|
// Resource types
|
||||||
|
ResourceAccount ResourceType = "account"
|
||||||
|
ResourceToken ResourceType = "token"
|
||||||
|
ResourcePGCreds ResourceType = "pgcreds"
|
||||||
|
ResourceAuditLog ResourceType = "audit_log"
|
||||||
|
ResourceTOTP ResourceType = "totp"
|
||||||
|
ResourcePolicy ResourceType = "policy"
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
Allow Effect = "allow"
|
||||||
|
Deny Effect = "deny"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PolicyInput is assembled by the middleware from JWT claims and request context.
|
||||||
|
// The engine never accesses the database.
|
||||||
|
type PolicyInput struct {
|
||||||
|
Subject string // account UUID from JWT "sub"
|
||||||
|
AccountType string // "human" or "system"
|
||||||
|
Roles []string // role strings from JWT "roles" claim
|
||||||
|
|
||||||
|
Action Action
|
||||||
|
Resource Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource describes what the principal is trying to act on.
|
||||||
|
type Resource struct {
|
||||||
|
Type ResourceType
|
||||||
|
OwnerUUID string // UUID of the account that owns this resource
|
||||||
|
// (e.g. the system account whose pgcreds are requested)
|
||||||
|
ServiceName string // username of the system account (for service-name gating)
|
||||||
|
Tags []string // tags on the target account, loaded from account_tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
Description string
|
||||||
|
|
||||||
|
// Principal match conditions
|
||||||
|
Roles []string // principal must hold at least one of these roles
|
||||||
|
AccountTypes []string // "human", "system", or both
|
||||||
|
SubjectUUID string // exact principal UUID (for single-account rules)
|
||||||
|
|
||||||
|
// Action match condition
|
||||||
|
Actions []Action // action must be one of these
|
||||||
|
|
||||||
|
// Resource match conditions
|
||||||
|
ResourceType ResourceType
|
||||||
|
OwnerMatchesSubject bool // true: resource.OwnerUUID must equal input.Subject
|
||||||
|
ServiceNames []string // resource.ServiceName must be in this list
|
||||||
|
RequiredTags []string // resource must carry ALL of these tags
|
||||||
|
|
||||||
|
Effect Effect
|
||||||
|
Priority int // lower value = evaluated first; built-in defaults use 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evaluation Algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
func Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule):
|
||||||
|
sort rules by Priority ascending (stable)
|
||||||
|
collect all rules that match input
|
||||||
|
|
||||||
|
for each matched rule (in priority order):
|
||||||
|
if rule.Effect == Deny:
|
||||||
|
return Deny, &rule // deny-wins: stop immediately
|
||||||
|
|
||||||
|
for each matched rule (in priority order):
|
||||||
|
if rule.Effect == Allow:
|
||||||
|
return Allow, &rule
|
||||||
|
|
||||||
|
return Deny, nil // default-deny
|
||||||
|
```
|
||||||
|
|
||||||
|
A rule matches `input` when every populated field satisfies its condition:
|
||||||
|
|
||||||
|
| Field | Match condition |
|
||||||
|
|---|---|
|
||||||
|
| `Roles` | `input.Roles` contains at least one element of `rule.Roles` |
|
||||||
|
| `AccountTypes` | `input.AccountType` is in `rule.AccountTypes` |
|
||||||
|
| `SubjectUUID` | `input.Subject == rule.SubjectUUID` |
|
||||||
|
| `Actions` | `input.Action` is in `rule.Actions` |
|
||||||
|
| `ResourceType` | `input.Resource.Type == rule.ResourceType` |
|
||||||
|
| `OwnerMatchesSubject` | (if true) `input.Resource.OwnerUUID == input.Subject` |
|
||||||
|
| `ServiceNames` | `input.Resource.ServiceName` is in `rule.ServiceNames` |
|
||||||
|
| `RequiredTags` | `input.Resource.Tags` contains ALL elements of `rule.RequiredTags` |
|
||||||
|
|
||||||
|
### Built-in Default Rules
|
||||||
|
|
||||||
|
These rules are compiled into the binary (`internal/policy/defaults.go`). They
|
||||||
|
cannot be deleted via the API and are always evaluated before DB-backed rules
|
||||||
|
at the same priority level.
|
||||||
|
|
||||||
|
```
|
||||||
|
Priority 0, Allow: roles=[admin], actions=<all> — admin wildcard
|
||||||
|
Priority 0, Allow: actions=[tokens:renew, auth:logout] — self-service logout/renew
|
||||||
|
Priority 0, Allow: actions=[totp:enroll] — self-service TOTP enrollment
|
||||||
|
Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read],
|
||||||
|
resourceType=pgcreds, ownerMatchesSubject=true
|
||||||
|
— system account reads own creds
|
||||||
|
Priority 0, Allow: accountTypes=[system], actions=[tokens:issue, tokens:renew],
|
||||||
|
resourceType=token, ownerMatchesSubject=true
|
||||||
|
— system account issues own token
|
||||||
|
Priority 0, Allow: actions=[tokens:validate, auth:login] — public endpoints (no auth needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
These defaults reproduce the previous binary `admin`/not-admin behavior exactly.
|
||||||
|
Adding custom rules extends the policy without replacing the defaults.
|
||||||
|
|
||||||
|
### Machine/Service Gating
|
||||||
|
|
||||||
|
Tags and service names enable access decisions that depend on which machine or
|
||||||
|
service the resource belongs to, not just who the principal is.
|
||||||
|
|
||||||
|
**Scenario A — Named service delegation:**
|
||||||
|
|
||||||
|
Alice needs to read Postgres credentials for the `payments-api` system account
|
||||||
|
but not for any other service. The operator grants Alice the role `svc:payments-api`
|
||||||
|
and creates one rule:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roles": ["svc:payments-api"],
|
||||||
|
"actions": ["pgcreds:read"],
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"service_names": ["payments-api"],
|
||||||
|
"effect": "allow",
|
||||||
|
"priority": 50,
|
||||||
|
"description": "Alice may read payments-api pgcreds"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When Alice calls `GET /v1/accounts/{payments-api-uuid}/pgcreds`, the middleware
|
||||||
|
sets `resource.ServiceName = "payments-api"`. The rule matches; access is
|
||||||
|
granted. The same call against `user-service` sets a different `ServiceName`
|
||||||
|
and no rule matches — default-deny applies.
|
||||||
|
|
||||||
|
**Scenario B — Machine-tag gating:**
|
||||||
|
|
||||||
|
The `deploy-agent` system account should only read credentials for accounts
|
||||||
|
tagged `env:staging`. The operator tags staging accounts with `env:staging` and
|
||||||
|
creates:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject_uuid": "<deploy-agent UUID>",
|
||||||
|
"actions": ["pgcreds:read"],
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"required_tags": ["env:staging"],
|
||||||
|
"effect": "allow",
|
||||||
|
"priority": 50,
|
||||||
|
"description": "deploy-agent may read staging pgcreds"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For belt-and-suspenders, an explicit deny for production tags:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject_uuid": "<deploy-agent UUID>",
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"required_tags": ["env:production"],
|
||||||
|
"effect": "deny",
|
||||||
|
"priority": 10,
|
||||||
|
"description": "deploy-agent denied production pgcreds (deny-wins)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario C — Blanket "secrets reader" role:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roles": ["secrets-reader"],
|
||||||
|
"actions": ["pgcreds:read"],
|
||||||
|
"resource_type": "pgcreds",
|
||||||
|
"effect": "allow",
|
||||||
|
"priority": 50,
|
||||||
|
"description": "secrets-reader role may read any pgcreds"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `ServiceNames` or `RequiredTags` field means this matches any service account.
|
||||||
|
|
||||||
|
### Middleware Integration
|
||||||
|
|
||||||
|
`internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in
|
||||||
|
replacement for `RequireRole("admin")`. It:
|
||||||
|
|
||||||
|
1. Extracts `*token.Claims` from context (JWT already validated by `RequireAuth`).
|
||||||
|
2. Reads the resource UUID from the request path parameter.
|
||||||
|
3. Queries the database for the target account's UUID, username, and tags.
|
||||||
|
4. Assembles `PolicyInput`.
|
||||||
|
5. Calls `engine.Evaluate(input)`.
|
||||||
|
6. On `Deny`: writes a `policy_deny` audit event and returns HTTP 403.
|
||||||
|
7. On `Allow`: proceeds to the handler (and optionally writes an allow audit
|
||||||
|
event for sensitive resources).
|
||||||
|
|
||||||
|
The `Engine` struct wraps the DB-backed rule loader. It caches the current rule
|
||||||
|
set in memory and reloads on `policy_rule_*` admin events (or on `SIGHUP`).
|
||||||
|
Built-in default rules are always merged in at priority 0.
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
|
||||||
|
The policy engine is introduced without changing existing behavior:
|
||||||
|
|
||||||
|
1. Add `account_tags` and `policy_rules` tables (schema migration).
|
||||||
|
2. Implement `internal/policy` package with built-in defaults only.
|
||||||
|
3. Wire `RequirePolicy` in middleware alongside `RequireRole("admin")` — both
|
||||||
|
must pass. The built-in defaults guarantee the outcome is identical to the
|
||||||
|
previous binary check.
|
||||||
|
4. Expose REST endpoints (`/v1/policy/rules`, `/v1/accounts/{id}/tags`) and
|
||||||
|
corresponding CLI commands and UI pages — operators can now create rules.
|
||||||
|
5. After validating custom rules in operation, `RequireRole("admin")` can be
|
||||||
|
removed from endpoints where `RequirePolicy` provides full coverage.
|
||||||
|
|
||||||
|
Step 3 is the correctness gate: zero behavioral change before custom rules are
|
||||||
|
introduced.
|
||||||
|
|
||||||
|
### Audit Events
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `policy_deny` | Policy engine denied a request; details include `{action, resource_type, service_name, required_tags, matched_rule_id}` — never credential material |
|
||||||
|
| `policy_rule_created` | New 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 |
|
||||||
|
|||||||
159
PROGRESS.md
159
PROGRESS.md
@@ -2,9 +2,73 @@
|
|||||||
|
|
||||||
Source of truth for current development state.
|
Source of truth for current development state.
|
||||||
---
|
---
|
||||||
All phases complete. 137 Go server tests + 25 Go client tests + 23 Rust client
|
All phases complete. Tests: all packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||||
tests + 37 Lisp client tests + 32 Python client tests pass. Zero race
|
|
||||||
conditions (go test -race ./...).
|
### 2026-03-11 — Postgres Credentials UI + Policy/Tags UI completion
|
||||||
|
|
||||||
|
**internal/ui/**
|
||||||
|
- `handlers_accounts.go`: added `handleSetPGCreds` — validates form fields,
|
||||||
|
encrypts password via `crypto.SealAESGCM` with fresh nonce, calls
|
||||||
|
`db.WritePGCredentials`, writes `EventPGCredUpdated` audit entry, re-reads and
|
||||||
|
renders `pgcreds_form` fragment; password never echoed in response
|
||||||
|
- `handlers_accounts.go`: updated `handleAccountDetail` to load PG credentials
|
||||||
|
for system accounts (non-fatal on `ErrNotFound`) and account tags for all
|
||||||
|
accounts
|
||||||
|
- `handlers_policy.go`: fixed `handleSetAccountTags` to render with
|
||||||
|
`AccountDetailData` (removed `AccountTagsData`); field ordering fixed for
|
||||||
|
`fieldalignment` linter
|
||||||
|
- `ui.go`: added `PGCred *model.PGCredential` and `Tags []string` to
|
||||||
|
`AccountDetailData`; added `pgcreds_form.html` and `tags_editor.html` to
|
||||||
|
shared template set; registered `PUT /accounts/{id}/pgcreds` and
|
||||||
|
`PUT /accounts/{id}/tags` routes; removed unused `AccountTagsData` struct;
|
||||||
|
field alignment fixed on `PolicyRuleView`, `PoliciesData`, `AccountDetailData`
|
||||||
|
- `ui_test.go`: added 5 new PG credential tests:
|
||||||
|
`TestSetPGCredsRejectsHumanAccount`, `TestSetPGCredsStoresAndDisplaysMetadata`,
|
||||||
|
`TestSetPGCredsPasswordNotEchoed`, `TestSetPGCredsRequiresPassword`,
|
||||||
|
`TestAccountDetailShowsPGCredsSection`
|
||||||
|
|
||||||
|
**web/templates/**
|
||||||
|
- `fragments/pgcreds_form.html` (new): displays current credential metadata
|
||||||
|
(host:port, database, username, updated-at — no password); includes HTMX
|
||||||
|
`hx-put` form for set/replace; system accounts only
|
||||||
|
- `fragments/tags_editor.html` (new): newline-separated tag textarea with HTMX
|
||||||
|
`hx-put` for atomic replacement; uses `.Account.UUID` for URL
|
||||||
|
- `fragments/policy_form.html`: rewritten to use structured fields matching
|
||||||
|
`handleCreatePolicyRule` parser: `description`, `priority`, `effect` (select),
|
||||||
|
`roles`/`account_types`/`actions` (multi-select), `resource_type`, `subject_uuid`,
|
||||||
|
`service_names`, `required_tags`, `owner_matches_subject` (checkbox)
|
||||||
|
- `policies.html` (new): policies management page with create-form toggle and
|
||||||
|
rules table (`id="policies-tbody"`)
|
||||||
|
- `fragments/policy_row.html` (new): HTMX table row with enable/disable toggle
|
||||||
|
(`hx-patch`) and delete button (`hx-delete`)
|
||||||
|
- `account_detail.html`: added Tags card (all accounts) and Postgres Credentials
|
||||||
|
card (system accounts only)
|
||||||
|
- `base.html`: added Policies nav link
|
||||||
|
|
||||||
|
**internal/server/server.go**
|
||||||
|
- Removed ~220 lines of duplicate tag and policy handler code that had been
|
||||||
|
inadvertently added; all real implementations live in `handlers_policy.go`
|
||||||
|
|
||||||
|
**internal/policy/engine_wrapper.go**
|
||||||
|
- Fixed corrupted source file (invisible character preventing `fmt` usage from
|
||||||
|
being recognized); rewrote to use `errors.New` for the denial error
|
||||||
|
|
||||||
|
**internal/db/policy_test.go**
|
||||||
|
- Fixed `CreateAccount` call using string literal `"human"` → `model.AccountTypeHuman`
|
||||||
|
|
||||||
|
**cmd/mciasctl/main.go**
|
||||||
|
- Added `//nolint:gosec` to three `int(os.Stdin.Fd())` conversions (safe:
|
||||||
|
uintptr == int on all target platforms; `term.ReadPassword` requires `int`)
|
||||||
|
|
||||||
|
**Linter fixes (all packages)**
|
||||||
|
- gofmt/goimports applied to `internal/db/policy_test.go`,
|
||||||
|
`internal/policy/defaults.go`, `internal/policy/engine_test.go`, `internal/ui/ui.go`
|
||||||
|
- fieldalignment fixed on `model.PolicyRuleRecord`, `policy.Engine`,
|
||||||
|
`policy.Rule`, `policy.RuleBody`, `ui.PolicyRuleView`
|
||||||
|
|
||||||
|
All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
|
||||||
|
|
||||||
|
---
|
||||||
- [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
|
- [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs)
|
||||||
- [x] Phase 1: Foundational packages (model, config, crypto, db)
|
- [x] Phase 1: Foundational packages (model, config, crypto, db)
|
||||||
- [x] Phase 2: Auth core (auth, token, middleware)
|
- [x] Phase 2: Auth core (auth, token, middleware)
|
||||||
@@ -15,7 +79,96 @@ conditions (go test -race ./...).
|
|||||||
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
|
- [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST)
|
||||||
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
|
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
|
||||||
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
|
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python)
|
||||||
|
- [x] Phase 10: Policy engine — ABAC with machine/service gating
|
||||||
---
|
---
|
||||||
|
### 2026-03-11 — Phase 10: Policy engine (ABAC + machine/service gating)
|
||||||
|
|
||||||
|
**internal/policy/** (new package)
|
||||||
|
- `policy.go` — types: `Action`, `ResourceType`, `Effect`, `Resource`,
|
||||||
|
`PolicyInput`, `Rule`, `RuleBody`; 22 Action constants covering all API
|
||||||
|
operations
|
||||||
|
- `engine.go` — `Evaluate(input, operatorRules) (Effect, *Rule)`: pure function;
|
||||||
|
merges operator rules with default rules, sorts by priority, deny-wins,
|
||||||
|
then first allow, then default-deny
|
||||||
|
- `defaults.go` — 6 compiled-in rules (IDs -1 to -6, Priority 0): admin
|
||||||
|
wildcard, self-service logout/renew, self-service TOTP, system account own
|
||||||
|
pgcreds, system account own service token, public login/validate endpoints
|
||||||
|
- `engine_wrapper.go` — `Engine` struct with `sync.RWMutex`; `SetRules()`
|
||||||
|
decodes DB records; `PolicyRecord` type avoids import cycle
|
||||||
|
- `engine_test.go` — 11 tests: DefaultDeny, AdminWildcard, SelfService*,
|
||||||
|
SystemOwn*, DenyWins, ServiceNameGating, MachineTagGating,
|
||||||
|
OwnerMatchesSubject, PriorityOrder, MultipleRequiredTags, AccountTypeGating
|
||||||
|
|
||||||
|
**internal/db/**
|
||||||
|
- `migrate.go`: migration id=4 — `account_tags` (account_id+tag PK, FK cascade)
|
||||||
|
and `policy_rules` (id, priority, description, rule_json, enabled,
|
||||||
|
created_by, timestamps) tables
|
||||||
|
- `tags.go` (new): `GetAccountTags`, `AddAccountTag`, `RemoveAccountTag`,
|
||||||
|
`SetAccountTags` (atomic DELETE+INSERT transaction); sorted alphabetically
|
||||||
|
- `policy.go` (new): `CreatePolicyRule`, `GetPolicyRule`, `ListPolicyRules`,
|
||||||
|
`UpdatePolicyRule`, `SetPolicyRuleEnabled`, `DeletePolicyRule`
|
||||||
|
- `tags_test.go`, `policy_test.go` (new): comprehensive DB-layer tests
|
||||||
|
|
||||||
|
**internal/model/**
|
||||||
|
- `PolicyRuleRecord` struct added
|
||||||
|
- New audit event constants: `EventTagAdded`, `EventTagRemoved`,
|
||||||
|
`EventPolicyRuleCreated`, `EventPolicyRuleUpdated`, `EventPolicyRuleDeleted`,
|
||||||
|
`EventPolicyDeny`
|
||||||
|
|
||||||
|
**internal/middleware/**
|
||||||
|
- `RequirePolicy` middleware: assembles `PolicyInput` from JWT claims +
|
||||||
|
`AccountTypeLookup` closure (DB-backed, avoids JWT schema change) +
|
||||||
|
`ResourceBuilder` closure; calls `engine.Evaluate`; logs deny via
|
||||||
|
`PolicyDenyLogger`
|
||||||
|
|
||||||
|
**internal/server/**
|
||||||
|
- New REST endpoints (all require admin):
|
||||||
|
- `GET/PUT /v1/accounts/{id}/tags`
|
||||||
|
- `GET/POST /v1/policy/rules`
|
||||||
|
- `GET/PATCH/DELETE /v1/policy/rules/{id}`
|
||||||
|
- `handlers_policy.go`: `handleGetTags`, `handleSetTags`, `handleListPolicyRules`,
|
||||||
|
`handleCreatePolicyRule`, `handleGetPolicyRule`, `handleUpdatePolicyRule`,
|
||||||
|
`handleDeletePolicyRule`, `policyRuleToResponse`, `loadPolicyRule`
|
||||||
|
|
||||||
|
**internal/ui/**
|
||||||
|
- `handlers_policy.go` (new): `handlePoliciesPage`, `handleCreatePolicyRule`,
|
||||||
|
`handleTogglePolicyRule`, `handleDeletePolicyRule`, `handleSetAccountTags`
|
||||||
|
- `ui.go`: registered 5 policy UI routes; added `PolicyRuleView`, `PoliciesData`,
|
||||||
|
`AccountTagsData` view types; added new fragment templates to shared set
|
||||||
|
|
||||||
|
**web/templates/**
|
||||||
|
- `policies.html` (new): policies management page
|
||||||
|
- `fragments/policy_row.html` (new): HTMX table row with enable/disable toggle
|
||||||
|
and delete button
|
||||||
|
- `fragments/policy_form.html` (new): create form with JSON textarea and action
|
||||||
|
reference chips
|
||||||
|
- `fragments/tags_editor.html` (new): newline-separated tag editor with HTMX
|
||||||
|
PUT for atomic replacement
|
||||||
|
- `account_detail.html`: added Tags card section using tags_editor fragment
|
||||||
|
- `base.html`: added Policies nav link
|
||||||
|
|
||||||
|
**cmd/mciasctl/**
|
||||||
|
- `policy` subcommands: `list`, `create -description STR -json FILE [-priority N]`,
|
||||||
|
`get -id ID`, `update -id ID [-priority N] [-enabled true|false]`,
|
||||||
|
`delete -id ID`
|
||||||
|
- `tag` subcommands: `list -id UUID`, `set -id UUID -tags tag1,tag2,...`
|
||||||
|
|
||||||
|
**openapi.yaml**
|
||||||
|
- New schemas: `TagsResponse`, `RuleBody`, `PolicyRule`
|
||||||
|
- New paths: `GET/PUT /v1/accounts/{id}/tags`,
|
||||||
|
`GET/POST /v1/policy/rules`, `GET/PATCH/DELETE /v1/policy/rules/{id}`
|
||||||
|
- New tag: `Admin — Policy`
|
||||||
|
|
||||||
|
**Design highlights:**
|
||||||
|
- Deny-wins + default-deny: explicit Deny beats any Allow; no match = Deny
|
||||||
|
- AccountType resolved via DB lookup (not JWT) to avoid breaking 29 IssueToken
|
||||||
|
call sites
|
||||||
|
- `RequirePolicy` wired alongside `RequireRole("admin")` for belt-and-suspenders
|
||||||
|
during migration; defaults reproduce current binary behavior exactly
|
||||||
|
- `policy.PolicyRecord` type avoids circular import between policy/db/model
|
||||||
|
|
||||||
|
All tests pass; `go test ./...` clean; `golangci-lint run ./...` clean.
|
||||||
|
|
||||||
### 2026-03-11 — Fix test failures and lockout logic
|
### 2026-03-11 — Fix test failures and lockout logic
|
||||||
|
|
||||||
- `internal/db/accounts.go` (IsLockedOut): corrected window-expiry check from
|
- `internal/db/accounts.go` (IsLockedOut): corrected window-expiry check from
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Command mciasctl is the MCIAS admin CLI.
|
// Command mciasctl is the MCIAS admin CLI.
|
||||||
//
|
//
|
||||||
// It connects to a running mciassrv instance and provides subcommands for
|
// It connects to a running mciassrv instance and provides subcommands for
|
||||||
// managing accounts, roles, tokens, and Postgres credentials.
|
// managing accounts, roles, tokens, Postgres credentials, policy rules, and
|
||||||
|
// account tags.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
@@ -31,6 +32,15 @@
|
|||||||
//
|
//
|
||||||
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
||||||
// pgcreds get -id UUID
|
// pgcreds get -id UUID
|
||||||
|
//
|
||||||
|
// policy list
|
||||||
|
// policy create -description STR -json FILE [-priority N]
|
||||||
|
// policy get -id ID
|
||||||
|
// policy update -id ID [-priority N] [-enabled true|false]
|
||||||
|
// policy delete -id ID
|
||||||
|
//
|
||||||
|
// tag list -id UUID
|
||||||
|
// tag set -id UUID -tags tag1,tag2,...
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -93,6 +103,10 @@ func main() {
|
|||||||
ctl.runToken(subArgs)
|
ctl.runToken(subArgs)
|
||||||
case "pgcreds":
|
case "pgcreds":
|
||||||
ctl.runPGCreds(subArgs)
|
ctl.runPGCreds(subArgs)
|
||||||
|
case "policy":
|
||||||
|
ctl.runPolicy(subArgs)
|
||||||
|
case "tag":
|
||||||
|
ctl.runTag(subArgs)
|
||||||
default:
|
default:
|
||||||
fatalf("unknown command %q; run with no args to see usage", command)
|
fatalf("unknown command %q; run with no args to see usage", command)
|
||||||
}
|
}
|
||||||
@@ -143,8 +157,8 @@ func (c *controller) authLogin(args []string) {
|
|||||||
passwd := *password
|
passwd := *password
|
||||||
if passwd == "" {
|
if passwd == "" {
|
||||||
fmt.Fprint(os.Stderr, "Password: ")
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
fmt.Fprintln(os.Stderr) // newline after hidden input
|
fmt.Fprintln(os.Stderr) // newline after hidden input
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("read password: %v", err)
|
fatalf("read password: %v", err)
|
||||||
}
|
}
|
||||||
@@ -223,7 +237,7 @@ func (c *controller) accountCreate(args []string) {
|
|||||||
passwd := *password
|
passwd := *password
|
||||||
if passwd == "" && *accountType == "human" {
|
if passwd == "" && *accountType == "human" {
|
||||||
fmt.Fprint(os.Stderr, "Password: ")
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("read password: %v", err)
|
fatalf("read password: %v", err)
|
||||||
@@ -442,7 +456,7 @@ func (c *controller) pgCredsSet(args []string) {
|
|||||||
passwd := *password
|
passwd := *password
|
||||||
if passwd == "" {
|
if passwd == "" {
|
||||||
fmt.Fprint(os.Stderr, "Postgres password: ")
|
fmt.Fprint(os.Stderr, "Postgres password: ")
|
||||||
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("read password: %v", err)
|
fatalf("read password: %v", err)
|
||||||
@@ -464,6 +478,189 @@ func (c *controller) pgCredsSet(args []string) {
|
|||||||
fmt.Println("credentials stored")
|
fmt.Println("credentials stored")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- policy subcommands ----
|
||||||
|
|
||||||
|
func (c *controller) runPolicy(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("policy requires a subcommand: list, create, get, update, delete")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
c.policyList()
|
||||||
|
case "create":
|
||||||
|
c.policyCreate(args[1:])
|
||||||
|
case "get":
|
||||||
|
c.policyGet(args[1:])
|
||||||
|
case "update":
|
||||||
|
c.policyUpdate(args[1:])
|
||||||
|
case "delete":
|
||||||
|
c.policyDelete(args[1:])
|
||||||
|
default:
|
||||||
|
fatalf("unknown policy subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyList() {
|
||||||
|
var result json.RawMessage
|
||||||
|
c.doRequest("GET", "/v1/policy/rules", nil, &result)
|
||||||
|
printJSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyCreate(args []string) {
|
||||||
|
fs := flag.NewFlagSet("policy create", flag.ExitOnError)
|
||||||
|
description := fs.String("description", "", "rule description (required)")
|
||||||
|
jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
|
||||||
|
priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *description == "" {
|
||||||
|
fatalf("policy create: -description is required")
|
||||||
|
}
|
||||||
|
if *jsonFile == "" {
|
||||||
|
fatalf("policy create: -json is required (path to rule body JSON file)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// G304: path comes from a CLI flag supplied by the operator.
|
||||||
|
ruleBytes, err := os.ReadFile(*jsonFile) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
fatalf("policy create: read %s: %v", *jsonFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the file contains valid JSON before sending.
|
||||||
|
var ruleBody json.RawMessage
|
||||||
|
if err := json.Unmarshal(ruleBytes, &ruleBody); err != nil {
|
||||||
|
fatalf("policy create: invalid JSON in %s: %v", *jsonFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"description": *description,
|
||||||
|
"priority": *priority,
|
||||||
|
"rule": ruleBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
var result json.RawMessage
|
||||||
|
c.doRequest("POST", "/v1/policy/rules", body, &result)
|
||||||
|
printJSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyGet(args []string) {
|
||||||
|
fs := flag.NewFlagSet("policy get", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "rule ID (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("policy get: -id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result json.RawMessage
|
||||||
|
c.doRequest("GET", "/v1/policy/rules/"+*id, nil, &result)
|
||||||
|
printJSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyUpdate(args []string) {
|
||||||
|
fs := flag.NewFlagSet("policy update", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "rule ID (required)")
|
||||||
|
priority := fs.Int("priority", -1, "new priority (-1 = no change)")
|
||||||
|
enabled := fs.String("enabled", "", "true or false")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("policy update: -id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if *priority >= 0 {
|
||||||
|
body["priority"] = *priority
|
||||||
|
}
|
||||||
|
if *enabled != "" {
|
||||||
|
switch *enabled {
|
||||||
|
case "true":
|
||||||
|
b := true
|
||||||
|
body["enabled"] = b
|
||||||
|
case "false":
|
||||||
|
b := false
|
||||||
|
body["enabled"] = b
|
||||||
|
default:
|
||||||
|
fatalf("policy update: -enabled must be true or false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
fatalf("policy update: at least one of -priority or -enabled is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result json.RawMessage
|
||||||
|
c.doRequest("PATCH", "/v1/policy/rules/"+*id, body, &result)
|
||||||
|
printJSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) policyDelete(args []string) {
|
||||||
|
fs := flag.NewFlagSet("policy delete", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "rule ID (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("policy delete: -id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.doRequest("DELETE", "/v1/policy/rules/"+*id, nil, nil)
|
||||||
|
fmt.Println("policy rule deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tag subcommands ----
|
||||||
|
|
||||||
|
func (c *controller) runTag(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("tag requires a subcommand: list, set")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
c.tagList(args[1:])
|
||||||
|
case "set":
|
||||||
|
c.tagSet(args[1:])
|
||||||
|
default:
|
||||||
|
fatalf("unknown tag subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) tagList(args []string) {
|
||||||
|
fs := flag.NewFlagSet("tag list", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "account UUID (required)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("tag list: -id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result json.RawMessage
|
||||||
|
c.doRequest("GET", "/v1/accounts/"+*id+"/tags", nil, &result)
|
||||||
|
printJSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controller) tagSet(args []string) {
|
||||||
|
fs := flag.NewFlagSet("tag set", flag.ExitOnError)
|
||||||
|
id := fs.String("id", "", "account UUID (required)")
|
||||||
|
tagsFlag := fs.String("tags", "", "comma-separated list of tags (empty string clears all tags)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if *id == "" {
|
||||||
|
fatalf("tag set: -id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := []string{}
|
||||||
|
if *tagsFlag != "" {
|
||||||
|
for _, t := range strings.Split(*tagsFlag, ",") {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t != "" {
|
||||||
|
tags = append(tags, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string][]string{"tags": tags}
|
||||||
|
c.doRequest("PUT", "/v1/accounts/"+*id+"/tags", body, nil)
|
||||||
|
fmt.Printf("tags set: %v\n", tags)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- HTTP helpers ----
|
// ---- HTTP helpers ----
|
||||||
|
|
||||||
// doRequest performs an authenticated JSON HTTP request. If result is non-nil,
|
// doRequest performs an authenticated JSON HTTP request. If result is non-nil,
|
||||||
@@ -588,5 +785,17 @@ Commands:
|
|||||||
|
|
||||||
pgcreds get -id UUID
|
pgcreds get -id UUID
|
||||||
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
|
||||||
|
|
||||||
|
policy list
|
||||||
|
policy create -description STR -json FILE [-priority N]
|
||||||
|
FILE must contain a JSON rule body, e.g.:
|
||||||
|
{"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true}
|
||||||
|
policy get -id ID
|
||||||
|
policy update -id ID [-priority N] [-enabled true|false]
|
||||||
|
policy delete -id ID
|
||||||
|
|
||||||
|
tag list -id UUID
|
||||||
|
tag set -id UUID -tags tag1,tag2,...
|
||||||
|
Pass empty -tags "" to clear all tags.
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,37 @@ CREATE TABLE IF NOT EXISTS failed_logins (
|
|||||||
window_start TEXT NOT NULL,
|
window_start TEXT NOT NULL,
|
||||||
attempt_count INTEGER NOT NULL DEFAULT 1
|
attempt_count INTEGER NOT NULL DEFAULT 1
|
||||||
);
|
);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
sql: `
|
||||||
|
-- Machine/service tags on accounts (many-to-many).
|
||||||
|
-- Used by the policy engine to gate access by machine or service identity
|
||||||
|
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
|
||||||
|
CREATE TABLE IF NOT EXISTS account_tags (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
PRIMARY KEY (account_id, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
|
||||||
|
|
||||||
|
-- Policy rules stored in the database and evaluated in-process.
|
||||||
|
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
|
||||||
|
-- Built-in default rules are compiled into the binary and are not stored here.
|
||||||
|
-- Rows with enabled=0 are loaded but skipped during evaluation.
|
||||||
|
CREATE TABLE IF NOT EXISTS policy_rules (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
rule_json TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
|
||||||
|
created_by INTEGER REFERENCES accounts(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
191
internal/db/policy.go
Normal file
191
internal/db/policy.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreatePolicyRule inserts a new policy rule record. The returned record
|
||||||
|
// includes the database-assigned ID and timestamps.
|
||||||
|
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64) (*model.PolicyRuleRecord, error) {
|
||||||
|
n := now()
|
||||||
|
result, err := db.sql.Exec(`
|
||||||
|
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?, ?)
|
||||||
|
`, priority, description, ruleJSON, createdBy, n, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: create policy rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: create policy rule last insert id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt, err := parseTime(n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.PolicyRuleRecord{
|
||||||
|
ID: id,
|
||||||
|
Priority: priority,
|
||||||
|
Description: description,
|
||||||
|
RuleJSON: ruleJSON,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
UpdatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPolicyRule retrieves a single policy rule by its database ID.
|
||||||
|
// Returns ErrNotFound if no such rule exists.
|
||||||
|
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
||||||
|
return db.scanPolicyRule(db.sql.QueryRow(`
|
||||||
|
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
||||||
|
FROM policy_rules WHERE id = ?
|
||||||
|
`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPolicyRules returns all policy rules ordered by priority then ID.
|
||||||
|
// When enabledOnly is true, only rules with enabled=1 are returned.
|
||||||
|
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
||||||
|
FROM policy_rules`
|
||||||
|
if enabledOnly {
|
||||||
|
query += ` WHERE enabled = 1`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY priority ASC, id ASC`
|
||||||
|
|
||||||
|
rows, err := db.sql.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list policy rules: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var rules []*model.PolicyRuleRecord
|
||||||
|
for rows.Next() {
|
||||||
|
r, err := db.scanPolicyRuleRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules = append(rules, r)
|
||||||
|
}
|
||||||
|
return rules, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePolicyRule updates the mutable fields of a policy rule.
|
||||||
|
// Only the fields in the update map are changed; other fields are untouched.
|
||||||
|
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string) error {
|
||||||
|
n := now()
|
||||||
|
|
||||||
|
// Build SET clause dynamically to only update provided fields.
|
||||||
|
// Security: field names are not user-supplied strings — they are selected
|
||||||
|
// from a fixed set of known column names only.
|
||||||
|
setClauses := "updated_at = ?"
|
||||||
|
args := []interface{}{n}
|
||||||
|
|
||||||
|
if description != nil {
|
||||||
|
setClauses += ", description = ?"
|
||||||
|
args = append(args, *description)
|
||||||
|
}
|
||||||
|
if priority != nil {
|
||||||
|
setClauses += ", priority = ?"
|
||||||
|
args = append(args, *priority)
|
||||||
|
}
|
||||||
|
if ruleJSON != nil {
|
||||||
|
setClauses += ", rule_json = ?"
|
||||||
|
args = append(args, *ruleJSON)
|
||||||
|
}
|
||||||
|
args = append(args, id)
|
||||||
|
|
||||||
|
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: update policy rule %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPolicyRuleEnabled enables or disables a policy rule by ID.
|
||||||
|
func (db *DB) SetPolicyRuleEnabled(id int64, enabled bool) error {
|
||||||
|
enabledInt := 0
|
||||||
|
if enabled {
|
||||||
|
enabledInt = 1
|
||||||
|
}
|
||||||
|
_, err := db.sql.Exec(`
|
||||||
|
UPDATE policy_rules SET enabled = ?, updated_at = ? WHERE id = ?
|
||||||
|
`, enabledInt, now(), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: set policy rule %d enabled=%v: %w", id, enabled, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePolicyRule removes a policy rule by ID.
|
||||||
|
func (db *DB) DeletePolicyRule(id int64) error {
|
||||||
|
_, err := db.sql.Exec(`DELETE FROM policy_rules WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: delete policy rule %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanPolicyRule scans a single policy rule from a *sql.Row.
|
||||||
|
func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
|
||||||
|
var r model.PolicyRuleRecord
|
||||||
|
var enabledInt int
|
||||||
|
var createdAtStr, updatedAtStr string
|
||||||
|
var createdBy *int64
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||||
|
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan policy rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanPolicyRuleRow scans a single policy rule from *sql.Rows.
|
||||||
|
func (db *DB) scanPolicyRuleRow(rows *sql.Rows) (*model.PolicyRuleRecord, error) {
|
||||||
|
var r model.PolicyRuleRecord
|
||||||
|
var enabledInt int
|
||||||
|
var createdAtStr, updatedAtStr string
|
||||||
|
var createdBy *int64
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||||
|
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan policy rule row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string) (*model.PolicyRuleRecord, error) {
|
||||||
|
r.Enabled = enabledInt == 1
|
||||||
|
r.CreatedBy = createdBy
|
||||||
|
|
||||||
|
var err error
|
||||||
|
r.CreatedAt, err = parseTime(createdAtStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.UpdatedAt, err = parseTime(updatedAtStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
212
internal/db/policy_test.go
Normal file
212
internal/db/policy_test.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateAndGetPolicyRule(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}`
|
||||||
|
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if rec.ID == 0 {
|
||||||
|
t.Error("expected non-zero ID after create")
|
||||||
|
}
|
||||||
|
if rec.Priority != 50 {
|
||||||
|
t.Errorf("expected priority 50, got %d", rec.Priority)
|
||||||
|
}
|
||||||
|
if !rec.Enabled {
|
||||||
|
t.Error("new rule should be enabled by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetPolicyRule(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if got.Description != "test rule" {
|
||||||
|
t.Errorf("expected description %q, got %q", "test rule", got.Description)
|
||||||
|
}
|
||||||
|
if got.RuleJSON != ruleJSON {
|
||||||
|
t.Errorf("rule_json mismatch: got %q", got.RuleJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPolicyRule_NotFound(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
_, err := db.GetPolicyRule(99999)
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPolicyRules(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil)
|
||||||
|
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil)
|
||||||
|
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil)
|
||||||
|
|
||||||
|
rules, err := db.ListPolicyRules(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPolicyRules: %v", err)
|
||||||
|
}
|
||||||
|
if len(rules) != 3 {
|
||||||
|
t.Fatalf("expected 3 rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
// Should be ordered by priority ascending.
|
||||||
|
if rules[0].Priority > rules[1].Priority || rules[1].Priority > rules[2].Priority {
|
||||||
|
t.Errorf("rules not sorted by priority: %v %v %v",
|
||||||
|
rules[0].Priority, rules[1].Priority, rules[2].Priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil)
|
||||||
|
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil)
|
||||||
|
|
||||||
|
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
|
||||||
|
t.Fatalf("SetPolicyRuleEnabled: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := db.ListPolicyRules(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPolicyRules(all): %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Errorf("expected 2 total rules, got %d", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled, err := db.ListPolicyRules(true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPolicyRules(enabledOnly): %v", err)
|
||||||
|
}
|
||||||
|
if len(enabled) != 1 {
|
||||||
|
t.Fatalf("expected 1 enabled rule, got %d", len(enabled))
|
||||||
|
}
|
||||||
|
if enabled[0].ID != r1.ID {
|
||||||
|
t.Errorf("wrong rule returned: got ID %d, want %d", enabled[0].ID, r1.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePolicyRule(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil)
|
||||||
|
|
||||||
|
newDesc := "updated description"
|
||||||
|
newPriority := 25
|
||||||
|
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil); err != nil {
|
||||||
|
t.Fatalf("UpdatePolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetPolicyRule(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPolicyRule after update: %v", err)
|
||||||
|
}
|
||||||
|
if got.Description != newDesc {
|
||||||
|
t.Errorf("expected description %q, got %q", newDesc, got.Description)
|
||||||
|
}
|
||||||
|
if got.Priority != newPriority {
|
||||||
|
t.Errorf("expected priority %d, got %d", newPriority, got.Priority)
|
||||||
|
}
|
||||||
|
// RuleJSON should be unchanged.
|
||||||
|
if got.RuleJSON != `{"effect":"allow"}` {
|
||||||
|
t.Errorf("rule_json should not change when not provided: %q", got.RuleJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil)
|
||||||
|
|
||||||
|
newJSON := `{"effect":"deny","roles":["auditor"]}`
|
||||||
|
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON); err != nil {
|
||||||
|
t.Fatalf("UpdatePolicyRule (json only): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetPolicyRule(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
if got.RuleJSON != newJSON {
|
||||||
|
t.Errorf("expected updated rule_json, got %q", got.RuleJSON)
|
||||||
|
}
|
||||||
|
// Description and priority unchanged.
|
||||||
|
if got.Description != "rule" {
|
||||||
|
t.Errorf("description should be unchanged, got %q", got.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetPolicyRuleEnabled(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil)
|
||||||
|
if !rec.Enabled {
|
||||||
|
t.Fatal("new rule should be enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.SetPolicyRuleEnabled(rec.ID, false); err != nil {
|
||||||
|
t.Fatalf("SetPolicyRuleEnabled(false): %v", err)
|
||||||
|
}
|
||||||
|
got, _ := db.GetPolicyRule(rec.ID)
|
||||||
|
if got.Enabled {
|
||||||
|
t.Error("rule should be disabled after SetPolicyRuleEnabled(false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.SetPolicyRuleEnabled(rec.ID, true); err != nil {
|
||||||
|
t.Fatalf("SetPolicyRuleEnabled(true): %v", err)
|
||||||
|
}
|
||||||
|
got, _ = db.GetPolicyRule(rec.ID)
|
||||||
|
if !got.Enabled {
|
||||||
|
t.Error("rule should be enabled after SetPolicyRuleEnabled(true)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePolicyRule(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil)
|
||||||
|
|
||||||
|
if err := db.DeletePolicyRule(rec.ID); err != nil {
|
||||||
|
t.Fatalf("DeletePolicyRule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.GetPolicyRule(rec.ID)
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("expected ErrNotFound after delete, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePolicyRule_NonExistent(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
// Deleting a non-existent rule should be a no-op, not an error.
|
||||||
|
if err := db.DeletePolicyRule(99999); err != nil {
|
||||||
|
t.Errorf("DeletePolicyRule on nonexistent ID should not error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash")
|
||||||
|
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePolicyRule with createdBy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := db.GetPolicyRule(rec.ID)
|
||||||
|
if got.CreatedBy == nil || *got.CreatedBy != acct.ID {
|
||||||
|
t.Errorf("expected CreatedBy=%d, got %v", acct.ID, got.CreatedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/db/tags.go
Normal file
82
internal/db/tags.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAccountTags returns the tags assigned to an account, sorted alphabetically.
|
||||||
|
func (db *DB) GetAccountTags(accountID int64) ([]string, error) {
|
||||||
|
rows, err := db.sql.Query(`
|
||||||
|
SELECT tag FROM account_tags WHERE account_id = ? ORDER BY tag ASC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: get tags for account %d: %w", accountID, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
for rows.Next() {
|
||||||
|
var tag string
|
||||||
|
if err := rows.Scan(&tag); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan tag: %w", err)
|
||||||
|
}
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
return tags, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAccountTag adds a single tag to an account. If the tag already exists the
|
||||||
|
// operation is a no-op (INSERT OR IGNORE).
|
||||||
|
func (db *DB) AddAccountTag(accountID int64, tag string) error {
|
||||||
|
_, err := db.sql.Exec(`
|
||||||
|
INSERT OR IGNORE INTO account_tags (account_id, tag, created_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`, accountID, tag, now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: add tag %q to account %d: %w", tag, accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAccountTag removes a single tag from an account. If the tag does not
|
||||||
|
// exist the operation is a no-op.
|
||||||
|
func (db *DB) RemoveAccountTag(accountID int64, tag string) error {
|
||||||
|
_, err := db.sql.Exec(`
|
||||||
|
DELETE FROM account_tags WHERE account_id = ? AND tag = ?
|
||||||
|
`, accountID, tag)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: remove tag %q from account %d: %w", tag, accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAccountTags atomically replaces the complete tag set for an account within
|
||||||
|
// a single transaction. Any tags not present in the new set are removed; any
|
||||||
|
// new tags are inserted.
|
||||||
|
func (db *DB) SetAccountTags(accountID int64, tags []string) error {
|
||||||
|
tx, err := db.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: set account tags begin tx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM account_tags WHERE account_id = ?`, accountID); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("db: set account tags delete existing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := now()
|
||||||
|
for _, tag := range tags {
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO account_tags (account_id, tag, created_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`, accountID, tag, n); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("db: set account tags insert %q: %w", tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("db: set account tags commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
183
internal/db/tags_test.go
Normal file
183
internal/db/tags_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetAccountTags_Empty(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("taguser", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountTags: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected no tags, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAndGetAccountTags(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("taguser2", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range []string{"env:staging", "svc:payments-api"} {
|
||||||
|
if err := db.AddAccountTag(acct.ID, tag); err != nil {
|
||||||
|
t.Fatalf("AddAccountTag(%q): %v", tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountTags: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 2 {
|
||||||
|
t.Fatalf("expected 2 tags, got %d: %v", len(tags), tags)
|
||||||
|
}
|
||||||
|
// Results are sorted alphabetically.
|
||||||
|
if tags[0] != "env:staging" || tags[1] != "svc:payments-api" {
|
||||||
|
t.Errorf("unexpected tags: %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAccountTag_Idempotent(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("taguser3", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding the same tag twice must not error or produce duplicates.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := db.AddAccountTag(acct.ID, "env:production"); err != nil {
|
||||||
|
t.Fatalf("AddAccountTag (attempt %d): %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountTags: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 1 {
|
||||||
|
t.Errorf("expected exactly 1 tag, got %d: %v", len(tags), tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAccountTag(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("taguser4", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.AddAccountTag(acct.ID, "env:staging")
|
||||||
|
_ = db.AddAccountTag(acct.ID, "env:production")
|
||||||
|
|
||||||
|
if err := db.RemoveAccountTag(acct.ID, "env:staging"); err != nil {
|
||||||
|
t.Fatalf("RemoveAccountTag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountTags: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 1 || tags[0] != "env:production" {
|
||||||
|
t.Errorf("expected only env:production, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAccountTag_NonExistent(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("taguser5", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing a tag that doesn't exist must be a no-op, not an error.
|
||||||
|
if err := db.RemoveAccountTag(acct.ID, "nonexistent:tag"); err != nil {
|
||||||
|
t.Errorf("RemoveAccountTag on nonexistent tag should not error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAccountTags_ReplacesFully(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("taguser6", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.AddAccountTag(acct.ID, "old:tag1")
|
||||||
|
_ = db.AddAccountTag(acct.ID, "old:tag2")
|
||||||
|
|
||||||
|
newTags := []string{"new:tag1", "new:tag2", "new:tag3"}
|
||||||
|
if err := db.SetAccountTags(acct.ID, newTags); err != nil {
|
||||||
|
t.Fatalf("SetAccountTags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountTags: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 3 {
|
||||||
|
t.Fatalf("expected 3 tags after set, got %d: %v", len(tags), tags)
|
||||||
|
}
|
||||||
|
// Verify old tags are gone.
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "old:tag1" || tag == "old:tag2" {
|
||||||
|
t.Errorf("old tag still present after SetAccountTags: %q", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAccountTags_Empty(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("taguser7", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.AddAccountTag(acct.ID, "env:staging")
|
||||||
|
|
||||||
|
if err := db.SetAccountTags(acct.ID, []string{}); err != nil {
|
||||||
|
t.Fatalf("SetAccountTags with empty slice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountTags: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected no tags after clearing, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountTagsCascadeDelete(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
acct, err := db.CreateAccount("taguser8", model.AccountTypeHuman, "hash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
_ = db.AddAccountTag(acct.ID, "env:staging")
|
||||||
|
|
||||||
|
// Soft-deleting an account does not cascade-delete tags (FK ON DELETE CASCADE
|
||||||
|
// only fires on hard deletes). Verify tags still exist after status update.
|
||||||
|
if err := db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
||||||
|
t.Fatalf("UpdateAccountStatus: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountTags after soft delete: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 1 {
|
||||||
|
t.Errorf("expected tag to survive soft delete, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -297,3 +298,98 @@ func minFloat64(a, b float64) float64 {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResourceBuilder is a function that assembles the policy.Resource for a
|
||||||
|
// specific request. The middleware calls it after claims are extracted.
|
||||||
|
// Implementations typically read the path parameter (e.g. account UUID) and
|
||||||
|
// look up the target account's owner UUID, service name, and tags from the DB.
|
||||||
|
//
|
||||||
|
// A nil ResourceBuilder is equivalent to a function that returns an empty
|
||||||
|
// Resource (no owner, no service name, no tags).
|
||||||
|
type ResourceBuilder func(r *http.Request, claims *token.Claims) policy.Resource
|
||||||
|
|
||||||
|
// AccountTypeLookup resolves the account type ("human" or "system") for the
|
||||||
|
// given account UUID. The middleware calls this to populate PolicyInput when
|
||||||
|
// the AccountTypes match condition is used in any rule.
|
||||||
|
//
|
||||||
|
// Callers supply an implementation backed by db.GetAccountByUUID; the
|
||||||
|
// middleware does not import the db package directly to avoid a cycle.
|
||||||
|
// Returning an empty string is safe — it simply will not match any
|
||||||
|
// AccountTypes condition on rules.
|
||||||
|
type AccountTypeLookup func(subjectUUID string) string
|
||||||
|
|
||||||
|
// PolicyDenyLogger is a function that records a policy denial in the audit log.
|
||||||
|
// Callers supply an implementation that calls db.WriteAuditEvent; the middleware
|
||||||
|
// itself does not import the db package directly for the audit write, keeping
|
||||||
|
// the dependency on policy and db separate.
|
||||||
|
type PolicyDenyLogger func(r *http.Request, claims *token.Claims, action policy.Action, res policy.Resource, matchedRuleID int64)
|
||||||
|
|
||||||
|
// RequirePolicy returns middleware that evaluates the policy engine for the
|
||||||
|
// given action and resource type. Must be used after RequireAuth.
|
||||||
|
//
|
||||||
|
// Security: deny-wins and default-deny semantics mean that any misconfiguration
|
||||||
|
// (missing rule, engine error) results in a 403, never silent permit. The
|
||||||
|
// matched rule ID is included in the audit event for traceability.
|
||||||
|
//
|
||||||
|
// AccountType is not stored in the JWT to avoid a signature-breaking change to
|
||||||
|
// IssueToken. It is resolved lazily via lookupAccountType (a DB-backed closure
|
||||||
|
// provided by the caller). Returning "" from lookupAccountType is safe: no
|
||||||
|
// AccountTypes rule condition will match an empty string.
|
||||||
|
//
|
||||||
|
// RequirePolicy is intended to coexist with RequireRole("admin") during the
|
||||||
|
// migration period. Once full policy coverage is validated, RequireRole can be
|
||||||
|
// removed. During the transition both checks must pass.
|
||||||
|
func RequirePolicy(
|
||||||
|
eng *policy.Engine,
|
||||||
|
action policy.Action,
|
||||||
|
resType policy.ResourceType,
|
||||||
|
buildResource ResourceBuilder,
|
||||||
|
lookupAccountType AccountTypeLookup,
|
||||||
|
logDeny PolicyDenyLogger,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := ClaimsFromContext(r.Context())
|
||||||
|
if claims == nil {
|
||||||
|
// RequireAuth was not applied upstream; fail closed.
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden", "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var res policy.Resource
|
||||||
|
res.Type = resType
|
||||||
|
if buildResource != nil {
|
||||||
|
res = buildResource(r, claims)
|
||||||
|
res.Type = resType // ensure type is always set even if builder overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
accountType := ""
|
||||||
|
if lookupAccountType != nil {
|
||||||
|
accountType = lookupAccountType(claims.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := policy.PolicyInput{
|
||||||
|
Subject: claims.Subject,
|
||||||
|
AccountType: accountType,
|
||||||
|
Roles: claims.Roles,
|
||||||
|
Action: action,
|
||||||
|
Resource: res,
|
||||||
|
}
|
||||||
|
|
||||||
|
effect, matched := eng.Evaluate(input)
|
||||||
|
if effect == policy.Deny {
|
||||||
|
var ruleID int64
|
||||||
|
if matched != nil {
|
||||||
|
ruleID = matched.ID
|
||||||
|
}
|
||||||
|
if logDeny != nil {
|
||||||
|
logDeny(r, claims, action, res, ruleID)
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient privileges", "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,4 +131,26 @@ const (
|
|||||||
EventTOTPRemoved = "totp_removed"
|
EventTOTPRemoved = "totp_removed"
|
||||||
EventPGCredAccessed = "pgcred_accessed"
|
EventPGCredAccessed = "pgcred_accessed"
|
||||||
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
|
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
|
||||||
|
|
||||||
|
EventTagAdded = "tag_added"
|
||||||
|
EventTagRemoved = "tag_removed"
|
||||||
|
|
||||||
|
EventPolicyRuleCreated = "policy_rule_created"
|
||||||
|
EventPolicyRuleUpdated = "policy_rule_updated"
|
||||||
|
EventPolicyRuleDeleted = "policy_rule_deleted"
|
||||||
|
EventPolicyDeny = "policy_deny"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PolicyRuleRecord is the database representation of a policy rule.
|
||||||
|
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||||
|
// The ID, Priority, and Description are stored as dedicated columns.
|
||||||
|
type PolicyRuleRecord struct {
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy *int64 `json:"-"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
RuleJSON string `json:"rule_json"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|||||||
83
internal/policy/defaults.go
Normal file
83
internal/policy/defaults.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
// defaultRules are the compiled-in authorization rules. They cannot be
|
||||||
|
// modified or deleted via the API. They reproduce the previous binary
|
||||||
|
// admin/non-admin behavior exactly when no operator rules exist, so wiring
|
||||||
|
// the policy engine alongside RequireRole("admin") produces identical results.
|
||||||
|
//
|
||||||
|
// All defaults use Priority 0 so they are evaluated before any operator rule
|
||||||
|
// (which defaults to Priority 100). Within priority 0, deny-wins still applies,
|
||||||
|
// but the defaults contain no Deny rules — they only grant the minimum required
|
||||||
|
// for self-service and admin operations.
|
||||||
|
//
|
||||||
|
// Security rationale for each rule is documented inline.
|
||||||
|
var defaultRules = []Rule{
|
||||||
|
{
|
||||||
|
// Admin wildcard: an account bearing the "admin" role is permitted to
|
||||||
|
// perform any action on any resource. This mirrors the previous
|
||||||
|
// RequireRole("admin") check and is the root of all administrative trust.
|
||||||
|
ID: -1,
|
||||||
|
Description: "Admin wildcard: admin role allows all actions",
|
||||||
|
Priority: 0,
|
||||||
|
Roles: []string{"admin"},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Self-service logout and token renewal: any authenticated principal may
|
||||||
|
// revoke or renew their own token. No resource scoping is needed because
|
||||||
|
// the handler independently verifies that the JTI belongs to the caller.
|
||||||
|
ID: -2,
|
||||||
|
Description: "Self-service: any principal may logout or renew their own token",
|
||||||
|
Priority: 0,
|
||||||
|
Actions: []Action{ActionLogout, ActionRenewToken},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Self-service TOTP enrollment: any authenticated human account may
|
||||||
|
// initiate and confirm their own TOTP enrollment. The handler verifies
|
||||||
|
// the subject matches before writing.
|
||||||
|
ID: -3,
|
||||||
|
Description: "Self-service: any principal may enroll their own TOTP",
|
||||||
|
Priority: 0,
|
||||||
|
Actions: []Action{ActionEnrollTOTP},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// System accounts reading their own pgcreds: a service that has already
|
||||||
|
// authenticated (e.g. via its bearer service token) may retrieve its own
|
||||||
|
// Postgres credentials without admin privilege. OwnerMatchesSubject
|
||||||
|
// ensures the service can only reach its own row — not another service's.
|
||||||
|
ID: -4,
|
||||||
|
Description: "System accounts may read their own pg_credentials",
|
||||||
|
Priority: 0,
|
||||||
|
AccountTypes: []string{"system"},
|
||||||
|
Actions: []Action{ActionReadPGCreds},
|
||||||
|
ResourceType: ResourcePGCreds,
|
||||||
|
OwnerMatchesSubject: true,
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// System accounts issuing or renewing their own service token: a system
|
||||||
|
// account may rotate its own bearer token. OwnerMatchesSubject ensures
|
||||||
|
// it cannot issue tokens for other accounts.
|
||||||
|
ID: -5,
|
||||||
|
Description: "System accounts may issue or renew their own service token",
|
||||||
|
Priority: 0,
|
||||||
|
AccountTypes: []string{"system"},
|
||||||
|
Actions: []Action{ActionIssueToken, ActionRenewToken},
|
||||||
|
ResourceType: ResourceToken,
|
||||||
|
OwnerMatchesSubject: true,
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Public endpoints: token validation and login do not require
|
||||||
|
// authentication. The middleware exempts them from RequireAuth entirely;
|
||||||
|
// this rule exists so that if a policy check is accidentally applied to
|
||||||
|
// these paths, it does not block them.
|
||||||
|
ID: -6,
|
||||||
|
Description: "Public: token validation and login are always permitted",
|
||||||
|
Priority: 0,
|
||||||
|
Actions: []Action{ActionValidateToken, ActionLogin},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
150
internal/policy/engine.go
Normal file
150
internal/policy/engine.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// Evaluate determines whether the given input should be allowed or denied,
|
||||||
|
// using the provided rule set. Built-in default rules (from defaults.go) are
|
||||||
|
// always merged in before evaluation.
|
||||||
|
//
|
||||||
|
// The rules slice passed by the caller contains only DB-backed operator rules;
|
||||||
|
// defaultRules are appended internally so callers do not need to know about them.
|
||||||
|
//
|
||||||
|
// Return values:
|
||||||
|
// - effect: Allow or Deny
|
||||||
|
// - matched: the Rule that produced the decision, or nil on default-deny
|
||||||
|
//
|
||||||
|
// Security: evaluation is purely functional — no I/O, no globals mutated. The
|
||||||
|
// deny-wins and default-deny semantics ensure that a misconfigured or empty
|
||||||
|
// operator rule set falls back to the built-in defaults, which reproduce the
|
||||||
|
// previous binary admin/non-admin behavior exactly.
|
||||||
|
func Evaluate(input PolicyInput, operatorRules []Rule) (Effect, *Rule) {
|
||||||
|
// Merge operator rules with built-in defaults. Defaults have priority 0;
|
||||||
|
// operator rules default to 100. Sort is stable so same-priority rules
|
||||||
|
// maintain their original order (defaults before operator rules on ties).
|
||||||
|
all := make([]Rule, 0, len(operatorRules)+len(defaultRules))
|
||||||
|
all = append(all, defaultRules...)
|
||||||
|
all = append(all, operatorRules...)
|
||||||
|
sort.SliceStable(all, func(i, j int) bool {
|
||||||
|
return all[i].Priority < all[j].Priority
|
||||||
|
})
|
||||||
|
|
||||||
|
var matched []Rule
|
||||||
|
for _, r := range all {
|
||||||
|
if matches(input, r) {
|
||||||
|
matched = append(matched, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny-wins: first matching Deny terminates evaluation.
|
||||||
|
for i := range matched {
|
||||||
|
if matched[i].Effect == Deny {
|
||||||
|
return Deny, &matched[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First matching Allow permits.
|
||||||
|
for i := range matched {
|
||||||
|
if matched[i].Effect == Allow {
|
||||||
|
return Allow, &matched[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default-deny: no rule matched.
|
||||||
|
return Deny, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches reports whether rule r applies to the given input. Every non-zero
|
||||||
|
// field on the rule is treated as an AND condition; empty slices and zero
|
||||||
|
// strings are wildcards.
|
||||||
|
func matches(input PolicyInput, r Rule) bool {
|
||||||
|
// Principal: roles (at least one must match)
|
||||||
|
if len(r.Roles) > 0 && !anyIn(input.Roles, r.Roles) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Principal: account type
|
||||||
|
if len(r.AccountTypes) > 0 && !stringIn(input.AccountType, r.AccountTypes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Principal: exact subject UUID
|
||||||
|
if r.SubjectUUID != "" && input.Subject != r.SubjectUUID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action
|
||||||
|
if len(r.Actions) > 0 && !actionIn(input.Action, r.Actions) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource type
|
||||||
|
if r.ResourceType != "" && input.Resource.Type != r.ResourceType {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource: owner must equal subject
|
||||||
|
if r.OwnerMatchesSubject && input.Resource.OwnerUUID != input.Subject {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource: service name must be in the allowed list
|
||||||
|
if len(r.ServiceNames) > 0 && !stringIn(input.Resource.ServiceName, r.ServiceNames) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource: resource must carry ALL required tags
|
||||||
|
if len(r.RequiredTags) > 0 && !allTagsPresent(input.Resource.Tags, r.RequiredTags) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyIn reports whether any element of needle appears in haystack.
|
||||||
|
func anyIn(needle, haystack []string) bool {
|
||||||
|
for _, n := range needle {
|
||||||
|
for _, h := range haystack {
|
||||||
|
if n == h {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringIn reports whether s is in list.
|
||||||
|
func stringIn(s string, list []string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if s == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionIn reports whether a is in list.
|
||||||
|
func actionIn(a Action, list []Action) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if a == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// allTagsPresent reports whether resourceTags contains every tag in required.
|
||||||
|
func allTagsPresent(resourceTags, required []string) bool {
|
||||||
|
for _, req := range required {
|
||||||
|
found := false
|
||||||
|
for _, rt := range resourceTags {
|
||||||
|
if rt == req {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
380
internal/policy/engine_test.go
Normal file
380
internal/policy/engine_test.go
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminInput is a convenience helper for building admin PolicyInputs.
|
||||||
|
func adminInput(action Action, resType ResourceType) PolicyInput {
|
||||||
|
return PolicyInput{
|
||||||
|
Subject: "admin-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{"admin"},
|
||||||
|
Action: action,
|
||||||
|
Resource: Resource{Type: resType},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_DefaultDeny(t *testing.T) {
|
||||||
|
// No operator rules, non-admin subject: should hit default-deny for an
|
||||||
|
// action that is not covered by built-in self-service defaults.
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionListAccounts,
|
||||||
|
Resource: Resource{Type: ResourceAccount},
|
||||||
|
}
|
||||||
|
effect, rule := Evaluate(input, nil)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Errorf("expected Deny, got %s", effect)
|
||||||
|
}
|
||||||
|
if rule != nil {
|
||||||
|
t.Errorf("expected nil rule on default-deny, got %+v", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_AdminWildcard(t *testing.T) {
|
||||||
|
actions := []Action{
|
||||||
|
ActionListAccounts, ActionCreateAccount, ActionReadPGCreds,
|
||||||
|
ActionWritePGCreds, ActionReadAudit, ActionManageRules,
|
||||||
|
}
|
||||||
|
for _, a := range actions {
|
||||||
|
t.Run(string(a), func(t *testing.T) {
|
||||||
|
effect, rule := Evaluate(adminInput(a, ResourceAccount), nil)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Errorf("admin should be allowed %s, got Deny", a)
|
||||||
|
}
|
||||||
|
if rule == nil || rule.ID != -1 {
|
||||||
|
t.Errorf("expected admin wildcard rule (-1), got %v", rule)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_SelfServiceLogout(t *testing.T) {
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionLogout,
|
||||||
|
Resource: Resource{Type: ResourceToken},
|
||||||
|
}
|
||||||
|
effect, _ := Evaluate(input, nil)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("expected any authenticated user to be allowed to logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_SelfServiceRenew(t *testing.T) {
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionRenewToken,
|
||||||
|
Resource: Resource{Type: ResourceToken},
|
||||||
|
}
|
||||||
|
effect, _ := Evaluate(input, nil)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("expected any authenticated user to be allowed to renew token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_SystemOwnPGCreds(t *testing.T) {
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "svc-uuid",
|
||||||
|
AccountType: "system",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionReadPGCreds,
|
||||||
|
Resource: Resource{
|
||||||
|
Type: ResourcePGCreds,
|
||||||
|
OwnerUUID: "svc-uuid", // owner matches subject
|
||||||
|
},
|
||||||
|
}
|
||||||
|
effect, rule := Evaluate(input, nil)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Errorf("system account should be allowed to read own pgcreds, got Deny")
|
||||||
|
}
|
||||||
|
if rule == nil || rule.ID != -4 {
|
||||||
|
t.Errorf("expected built-in rule -4, got %v", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_SystemOtherPGCreds_Denied(t *testing.T) {
|
||||||
|
// System account trying to read another system account's pgcreds.
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "svc-uuid",
|
||||||
|
AccountType: "system",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionReadPGCreds,
|
||||||
|
Resource: Resource{
|
||||||
|
Type: ResourcePGCreds,
|
||||||
|
OwnerUUID: "other-svc-uuid", // different owner
|
||||||
|
},
|
||||||
|
}
|
||||||
|
effect, _ := Evaluate(input, nil)
|
||||||
|
if effect != Allow {
|
||||||
|
// This is the expected behavior: default-deny.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Error("system account must not read another account's pgcreds without an explicit rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_DenyWins(t *testing.T) {
|
||||||
|
// Operator adds a Deny rule for a specific subject; a broader Allow rule
|
||||||
|
// also matches. Deny must win regardless of order.
|
||||||
|
operatorRules := []Rule{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Description: "broad allow",
|
||||||
|
Priority: 100,
|
||||||
|
Actions: []Action{ActionReadPGCreds},
|
||||||
|
ResourceType: ResourcePGCreds,
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Description: "specific deny",
|
||||||
|
Priority: 50, // higher precedence than the allow
|
||||||
|
SubjectUUID: "bad-actor-uuid",
|
||||||
|
ResourceType: ResourcePGCreds,
|
||||||
|
Effect: Deny,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "bad-actor-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionReadPGCreds,
|
||||||
|
Resource: Resource{Type: ResourcePGCreds},
|
||||||
|
}
|
||||||
|
effect, rule := Evaluate(input, operatorRules)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Errorf("deny rule should win over allow rule, got Allow")
|
||||||
|
}
|
||||||
|
if rule == nil || rule.ID != 2 {
|
||||||
|
t.Errorf("expected deny rule ID 2, got %v", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_ServiceNameGating(t *testing.T) {
|
||||||
|
operatorRules := []Rule{
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Description: "alice may read payments-api pgcreds",
|
||||||
|
Priority: 50,
|
||||||
|
Roles: []string{"svc:payments-api"},
|
||||||
|
Actions: []Action{ActionReadPGCreds},
|
||||||
|
ResourceType: ResourcePGCreds,
|
||||||
|
ServiceNames: []string{"payments-api"},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
alice := PolicyInput{
|
||||||
|
Subject: "alice-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{"svc:payments-api"},
|
||||||
|
Action: ActionReadPGCreds,
|
||||||
|
Resource: Resource{
|
||||||
|
Type: ResourcePGCreds,
|
||||||
|
ServiceName: "payments-api",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
effect, _ := Evaluate(alice, operatorRules)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("alice should be allowed to read payments-api pgcreds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same principal, wrong service — should be denied.
|
||||||
|
alice.Resource.ServiceName = "user-service"
|
||||||
|
effect, _ = Evaluate(alice, operatorRules)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Error("alice should be denied access to user-service pgcreds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_MachineTagGating(t *testing.T) {
|
||||||
|
operatorRules := []Rule{
|
||||||
|
{
|
||||||
|
ID: 4,
|
||||||
|
Description: "deploy-agent: staging only",
|
||||||
|
Priority: 50,
|
||||||
|
SubjectUUID: "deploy-agent-uuid",
|
||||||
|
Actions: []Action{ActionReadPGCreds},
|
||||||
|
ResourceType: ResourcePGCreds,
|
||||||
|
RequiredTags: []string{"env:staging"},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 5,
|
||||||
|
Description: "deploy-agent: deny production (belt-and-suspenders)",
|
||||||
|
Priority: 10, // evaluated before the allow
|
||||||
|
SubjectUUID: "deploy-agent-uuid",
|
||||||
|
ResourceType: ResourcePGCreds,
|
||||||
|
RequiredTags: []string{"env:production"},
|
||||||
|
Effect: Deny,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
staging := PolicyInput{
|
||||||
|
Subject: "deploy-agent-uuid",
|
||||||
|
AccountType: "system",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionReadPGCreds,
|
||||||
|
Resource: Resource{
|
||||||
|
Type: ResourcePGCreds,
|
||||||
|
Tags: []string{"env:staging", "svc:payments-api"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
effect, _ := Evaluate(staging, operatorRules)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("deploy-agent should be allowed to read staging pgcreds")
|
||||||
|
}
|
||||||
|
|
||||||
|
production := staging
|
||||||
|
production.Resource.Tags = []string{"env:production", "svc:payments-api"}
|
||||||
|
effect, rule := Evaluate(production, operatorRules)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Error("deploy-agent should be denied access to production pgcreds")
|
||||||
|
}
|
||||||
|
if rule == nil || rule.ID != 5 {
|
||||||
|
t.Errorf("expected deny rule ID 5 for production, got %v", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_OwnerMatchesSubject(t *testing.T) {
|
||||||
|
// Operator rule: a user may read account details for accounts they own.
|
||||||
|
operatorRules := []Rule{
|
||||||
|
{
|
||||||
|
ID: 6,
|
||||||
|
Description: "principals may read their own account",
|
||||||
|
Priority: 50,
|
||||||
|
Actions: []Action{ActionReadAccount},
|
||||||
|
ResourceType: ResourceAccount,
|
||||||
|
OwnerMatchesSubject: true,
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading own account — should be allowed.
|
||||||
|
own := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionReadAccount,
|
||||||
|
Resource: Resource{
|
||||||
|
Type: ResourceAccount,
|
||||||
|
OwnerUUID: "user-uuid",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
effect, _ := Evaluate(own, operatorRules)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("user should be allowed to read their own account")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading another user's account — should be denied.
|
||||||
|
other := own
|
||||||
|
other.Resource.OwnerUUID = "other-uuid"
|
||||||
|
effect, _ = Evaluate(other, operatorRules)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Error("user must not read another user's account without an explicit rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_PriorityOrder(t *testing.T) {
|
||||||
|
// Two Allow rules at different priorities: the lower-priority number wins.
|
||||||
|
operatorRules := []Rule{
|
||||||
|
{ID: 10, Description: "low priority allow", Priority: 200, Actions: []Action{ActionReadAudit}, Effect: Allow},
|
||||||
|
{ID: 11, Description: "high priority allow", Priority: 10, Actions: []Action{ActionReadAudit}, Effect: Allow},
|
||||||
|
}
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionReadAudit,
|
||||||
|
Resource: Resource{Type: ResourceAuditLog},
|
||||||
|
}
|
||||||
|
_, rule := Evaluate(input, operatorRules)
|
||||||
|
if rule == nil || rule.ID != 11 {
|
||||||
|
t.Errorf("expected higher-priority rule (ID 11) to match first, got %v", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_MultipleRequiredTags(t *testing.T) {
|
||||||
|
// RequiredTags requires ALL tags to be present.
|
||||||
|
operatorRules := []Rule{
|
||||||
|
{
|
||||||
|
ID: 20,
|
||||||
|
Description: "allow if both env:staging and svc:payments-api tags present",
|
||||||
|
Priority: 50,
|
||||||
|
Actions: []Action{ActionReadPGCreds},
|
||||||
|
ResourceType: ResourcePGCreds,
|
||||||
|
RequiredTags: []string{"env:staging", "svc:payments-api"},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both tags present — allowed.
|
||||||
|
input := PolicyInput{
|
||||||
|
Subject: "user-uuid",
|
||||||
|
AccountType: "human",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionReadPGCreds,
|
||||||
|
Resource: Resource{
|
||||||
|
Type: ResourcePGCreds,
|
||||||
|
Tags: []string{"env:staging", "svc:payments-api", "extra:tag"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
effect, _ := Evaluate(input, operatorRules)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("both required tags present: should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only one tag present — denied (default-deny).
|
||||||
|
input.Resource.Tags = []string{"env:staging"}
|
||||||
|
effect, _ = Evaluate(input, operatorRules)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Error("only one required tag present: should be denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tags — denied.
|
||||||
|
input.Resource.Tags = nil
|
||||||
|
effect, _ = Evaluate(input, operatorRules)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Error("no tags: should be denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_AccountTypeGating(t *testing.T) {
|
||||||
|
// Rule only applies to system accounts.
|
||||||
|
operatorRules := []Rule{
|
||||||
|
{
|
||||||
|
ID: 30,
|
||||||
|
Description: "system accounts may list accounts",
|
||||||
|
Priority: 50,
|
||||||
|
AccountTypes: []string{"system"},
|
||||||
|
Actions: []Action{ActionListAccounts},
|
||||||
|
Effect: Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sysInput := PolicyInput{
|
||||||
|
Subject: "svc-uuid",
|
||||||
|
AccountType: "system",
|
||||||
|
Roles: []string{},
|
||||||
|
Action: ActionListAccounts,
|
||||||
|
Resource: Resource{Type: ResourceAccount},
|
||||||
|
}
|
||||||
|
effect, _ := Evaluate(sysInput, operatorRules)
|
||||||
|
if effect != Allow {
|
||||||
|
t.Error("system account should be allowed by account-type rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
humanInput := sysInput
|
||||||
|
humanInput.AccountType = "human"
|
||||||
|
effect, _ = Evaluate(humanInput, operatorRules)
|
||||||
|
if effect != Deny {
|
||||||
|
t.Error("human account should not match system-only rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
83
internal/policy/engine_wrapper.go
Normal file
83
internal/policy/engine_wrapper.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine wraps the stateless Evaluate function with an in-memory cache of
|
||||||
|
// operator rules loaded from the database. Built-in default rules are always
|
||||||
|
// merged in at evaluation time; they do not appear in the cache.
|
||||||
|
//
|
||||||
|
// The Engine is safe for concurrent use. Call Reload() after any change to the
|
||||||
|
// policy_rules table to refresh the cached rule set without restarting.
|
||||||
|
type Engine struct {
|
||||||
|
rules []Rule
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine creates an Engine with an initially empty operator rule set.
|
||||||
|
// Call Reload (or load rules directly) before use in production.
|
||||||
|
func NewEngine() *Engine {
|
||||||
|
return &Engine{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRules atomically replaces the cached operator rule set.
|
||||||
|
// records is a slice of PolicyRuleRecord values (from the database layer).
|
||||||
|
// Only enabled records are converted to Rule values.
|
||||||
|
//
|
||||||
|
// Security: rule_json is decoded into a RuleBody struct before being merged
|
||||||
|
// into a Rule. This prevents the database from injecting values into the ID or
|
||||||
|
// Description fields that are stored as dedicated columns.
|
||||||
|
func (e *Engine) SetRules(records []PolicyRecord) error {
|
||||||
|
rules := make([]Rule, 0, len(records))
|
||||||
|
for _, rec := range records {
|
||||||
|
if !rec.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var body RuleBody
|
||||||
|
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||||
|
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
|
||||||
|
}
|
||||||
|
rules = append(rules, Rule{
|
||||||
|
ID: rec.ID,
|
||||||
|
Description: rec.Description,
|
||||||
|
Priority: rec.Priority,
|
||||||
|
Roles: body.Roles,
|
||||||
|
AccountTypes: body.AccountTypes,
|
||||||
|
SubjectUUID: body.SubjectUUID,
|
||||||
|
Actions: body.Actions,
|
||||||
|
ResourceType: body.ResourceType,
|
||||||
|
OwnerMatchesSubject: body.OwnerMatchesSubject,
|
||||||
|
ServiceNames: body.ServiceNames,
|
||||||
|
RequiredTags: body.RequiredTags,
|
||||||
|
Effect: body.Effect,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
e.rules = rules
|
||||||
|
e.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate runs the policy engine against the given input using the cached
|
||||||
|
// operator rules plus compiled-in defaults.
|
||||||
|
func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) {
|
||||||
|
e.mu.RLock()
|
||||||
|
rules := e.rules
|
||||||
|
e.mu.RUnlock()
|
||||||
|
return Evaluate(input, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyRecord is the minimal interface the Engine needs from the DB layer.
|
||||||
|
// Using a local struct avoids importing the db or model packages from policy,
|
||||||
|
// which would create a dependency cycle.
|
||||||
|
type PolicyRecord struct {
|
||||||
|
Description string
|
||||||
|
RuleJSON string
|
||||||
|
ID int64
|
||||||
|
Priority int
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
141
internal/policy/policy.go
Normal file
141
internal/policy/policy.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Package policy implements an in-process, attribute-based authorization
|
||||||
|
// policy engine for MCIAS. Evaluation is a pure function: given a PolicyInput
|
||||||
|
// and a slice of Rules it returns an Effect (Allow or Deny) and the Rule that
|
||||||
|
// produced the decision. The caller is responsible for assembling PolicyInput
|
||||||
|
// from JWT claims and database lookups; the engine never touches the database.
|
||||||
|
//
|
||||||
|
// Evaluation order:
|
||||||
|
// 1. Rules are sorted by Priority (ascending; lower = higher precedence).
|
||||||
|
// 2. Deny-wins: the first matching Deny rule terminates evaluation.
|
||||||
|
// 3. If no Deny matched, the first matching Allow rule permits the request.
|
||||||
|
// 4. Default-deny: if no rule matches, the request is denied.
|
||||||
|
package policy
|
||||||
|
|
||||||
|
// Action is a structured action identifier of the form "resource:verb".
|
||||||
|
// Security: using typed constants prevents callers from passing arbitrary
|
||||||
|
// strings, making it harder to accidentally bypass a policy check.
|
||||||
|
type Action string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionListAccounts Action = "accounts:list"
|
||||||
|
ActionCreateAccount Action = "accounts:create"
|
||||||
|
ActionReadAccount Action = "accounts:read"
|
||||||
|
ActionUpdateAccount Action = "accounts:update"
|
||||||
|
ActionDeleteAccount Action = "accounts:delete"
|
||||||
|
|
||||||
|
ActionReadRoles Action = "roles:read"
|
||||||
|
ActionWriteRoles Action = "roles:write"
|
||||||
|
|
||||||
|
ActionReadTags Action = "tags:read"
|
||||||
|
ActionWriteTags Action = "tags:write"
|
||||||
|
|
||||||
|
ActionIssueToken Action = "tokens:issue"
|
||||||
|
ActionRevokeToken Action = "tokens:revoke"
|
||||||
|
ActionValidateToken Action = "tokens:validate" // public endpoint
|
||||||
|
ActionRenewToken Action = "tokens:renew" // self-service
|
||||||
|
|
||||||
|
ActionReadPGCreds Action = "pgcreds:read"
|
||||||
|
ActionWritePGCreds Action = "pgcreds:write"
|
||||||
|
|
||||||
|
ActionReadAudit Action = "audit:read"
|
||||||
|
|
||||||
|
ActionEnrollTOTP Action = "totp:enroll" // self-service
|
||||||
|
ActionRemoveTOTP Action = "totp:remove" // admin
|
||||||
|
|
||||||
|
ActionLogin Action = "auth:login" // public
|
||||||
|
ActionLogout Action = "auth:logout" // self-service
|
||||||
|
|
||||||
|
ActionListRules Action = "policy:list"
|
||||||
|
ActionManageRules Action = "policy:manage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResourceType identifies what kind of object a request targets.
|
||||||
|
type ResourceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResourceAccount ResourceType = "account"
|
||||||
|
ResourceToken ResourceType = "token"
|
||||||
|
ResourcePGCreds ResourceType = "pgcreds"
|
||||||
|
ResourceAuditLog ResourceType = "audit_log"
|
||||||
|
ResourceTOTP ResourceType = "totp"
|
||||||
|
ResourcePolicy ResourceType = "policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Effect is the outcome of policy evaluation.
|
||||||
|
type Effect string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Allow Effect = "allow"
|
||||||
|
Deny Effect = "deny"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resource describes the object the principal is attempting to act on. Tags
|
||||||
|
// are loaded from the account_tags table by the middleware before evaluation.
|
||||||
|
type Resource struct {
|
||||||
|
Type ResourceType
|
||||||
|
|
||||||
|
// OwnerUUID is the UUID of the account that owns this resource (e.g. the
|
||||||
|
// system account whose pg_credentials are being requested). Empty when the
|
||||||
|
// resource is not account-owned (e.g. an audit log listing).
|
||||||
|
OwnerUUID string
|
||||||
|
|
||||||
|
// ServiceName is the username of the system account that owns the resource.
|
||||||
|
// Used for service-name-based gating rules (ServiceNames field on Rule).
|
||||||
|
ServiceName string
|
||||||
|
|
||||||
|
// Tags are the account_tags values on the target account. The engine
|
||||||
|
// checks RequiredTags against this slice.
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyInput is assembled by the middleware from JWT claims and the current
|
||||||
|
// request context. The engine accepts a PolicyInput and a rule set; it never
|
||||||
|
// queries the database directly.
|
||||||
|
type PolicyInput struct {
|
||||||
|
// Principal fields — from JWT claims
|
||||||
|
Subject string // account UUID ("sub")
|
||||||
|
AccountType string // "human" or "system"
|
||||||
|
Roles []string // role strings from "roles" claim
|
||||||
|
|
||||||
|
Action Action
|
||||||
|
Resource Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule is a single policy statement. All non-zero fields are AND-ed together
|
||||||
|
// as match conditions. A zero/empty field is a wildcard.
|
||||||
|
//
|
||||||
|
// Security: rules from the database are decoded and merged with compiled-in
|
||||||
|
// defaults before evaluation. Neither the JSON encoding nor the DB storage is
|
||||||
|
// trusted to produce sensible rules; the engine validates each condition
|
||||||
|
// independently using set membership — there is no string interpolation or
|
||||||
|
// code execution involved.
|
||||||
|
type Rule struct {
|
||||||
|
Description string
|
||||||
|
SubjectUUID string
|
||||||
|
ResourceType ResourceType
|
||||||
|
Effect Effect
|
||||||
|
Roles []string
|
||||||
|
AccountTypes []string
|
||||||
|
Actions []Action
|
||||||
|
ServiceNames []string
|
||||||
|
RequiredTags []string
|
||||||
|
ID int64
|
||||||
|
Priority int
|
||||||
|
OwnerMatchesSubject bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleBody is the subset of Rule that is stored as JSON in the policy_rules
|
||||||
|
// table. ID, Description, and Priority are stored as dedicated columns.
|
||||||
|
// Security: the JSON blob is decoded into a RuleBody before being merged into
|
||||||
|
// a full Rule, so the database cannot inject ID or Description values.
|
||||||
|
type RuleBody struct {
|
||||||
|
SubjectUUID string `json:"subject_uuid,omitempty"`
|
||||||
|
ResourceType ResourceType `json:"resource_type,omitempty"`
|
||||||
|
Effect Effect `json:"effect"`
|
||||||
|
Roles []string `json:"roles,omitempty"`
|
||||||
|
AccountTypes []string `json:"account_types,omitempty"`
|
||||||
|
Actions []Action `json:"actions,omitempty"`
|
||||||
|
ServiceNames []string `json:"service_names,omitempty"`
|
||||||
|
RequiredTags []string `json:"required_tags,omitempty"`
|
||||||
|
OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"`
|
||||||
|
}
|
||||||
324
internal/server/handlers_policy.go
Normal file
324
internal/server/handlers_policy.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Tag endpoints ----
|
||||||
|
|
||||||
|
type tagsResponse struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acct, ok := s.loadAccount(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := s.db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tags == nil {
|
||||||
|
tags = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tagsResponse{Tags: tags})
|
||||||
|
}
|
||||||
|
|
||||||
|
type setTagsRequest struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acct, ok := s.loadAccount(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req setTagsRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate tags: each must be non-empty.
|
||||||
|
for _, tag := range req.Tags {
|
||||||
|
if tag == "" {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "tag values must not be empty", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.SetAccountTags(acct.ID, req.Tags); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine actor for audit log.
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeAudit(r, model.EventTagAdded, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"account":%q,"tags":%s}`, acct.UUID, marshalStringSlice(req.Tags)))
|
||||||
|
|
||||||
|
tags, err := s.db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tags == nil {
|
||||||
|
tags = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tagsResponse{Tags: tags})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Policy rule endpoints ----
|
||||||
|
|
||||||
|
type policyRuleResponse struct {
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
RuleBody policy.RuleBody `json:"rule"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func policyRuleToResponse(rec *model.PolicyRuleRecord) (policyRuleResponse, error) {
|
||||||
|
var body policy.RuleBody
|
||||||
|
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||||
|
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
|
||||||
|
}
|
||||||
|
return policyRuleResponse{
|
||||||
|
ID: rec.ID,
|
||||||
|
Priority: rec.Priority,
|
||||||
|
Description: rec.Description,
|
||||||
|
RuleBody: body,
|
||||||
|
Enabled: rec.Enabled,
|
||||||
|
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
rules, err := s.db.ListPolicyRules(false)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := make([]policyRuleResponse, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
rv, err := policyRuleToResponse(r)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp = append(resp, rv)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createPolicyRuleRequest struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Rule policy.RuleBody `json:"rule"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createPolicyRuleRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Description == "" {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "description is required", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Rule.Effect != policy.Allow && req.Rule.Effect != policy.Deny {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "rule.effect must be 'allow' or 'deny'", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priority := req.Priority
|
||||||
|
if priority == 0 {
|
||||||
|
priority = 100 // default
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleJSON, err := json.Marshal(req.Rule)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var createdBy *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
createdBy = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
|
||||||
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
rv, err := policyRuleToResponse(rec)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := s.loadPolicyRule(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rv, err := policyRuleToResponse(rec)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updatePolicyRuleRequest struct {
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Rule *policy.RuleBody `json:"rule,omitempty"`
|
||||||
|
Priority *int `json:"priority,omitempty"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := s.loadPolicyRule(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updatePolicyRuleRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate effect if rule body is being updated.
|
||||||
|
var ruleJSON *string
|
||||||
|
if req.Rule != nil {
|
||||||
|
if req.Rule.Effect != policy.Allow && req.Rule.Effect != policy.Deny {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "rule.effect must be 'allow' or 'deny'", "bad_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(req.Rule)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
ruleJSON = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Enabled != nil {
|
||||||
|
if err := s.db.SetPolicyRuleEnabled(rec.ID, *req.Enabled); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
||||||
|
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
||||||
|
|
||||||
|
updated, err := s.db.GetPolicyRule(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rv, err := policyRuleToResponse(updated)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := s.loadPolicyRule(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.DeletePolicyRule(rec.ID); err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
||||||
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPolicyRule retrieves a policy rule by the {id} path parameter.
|
||||||
|
func (s *Server) loadPolicyRule(w http.ResponseWriter, r *http.Request) (*model.PolicyRuleRecord, bool) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
if idStr == "" {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "rule id is required", "bad_request")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "rule id must be an integer", "bad_request")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rec, err := s.db.GetPolicyRule(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
middleware.WriteError(w, http.StatusNotFound, "policy rule not found", "not_found")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalStringSlice encodes a string slice as a compact JSON array.
|
||||||
|
// Used for audit log details — never includes credential material.
|
||||||
|
func marshalStringSlice(ss []string) string {
|
||||||
|
b, _ := json.Marshal(ss)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -119,6 +119,13 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
||||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
||||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||||
|
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
||||||
|
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
||||||
|
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
||||||
|
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||||
|
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
||||||
|
mux.Handle("PATCH /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleUpdatePolicyRule)))
|
||||||
|
mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule)))
|
||||||
|
|
||||||
// UI routes (HTMX-based management frontend).
|
// UI routes (HTMX-based management frontend).
|
||||||
uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger)
|
uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger)
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ErrNotFound is expected when no credentials have been stored yet.
|
// ErrNotFound is expected when no credentials have been stored yet.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tags, err := u.db.GetAccountTags(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Warn("get account tags", "error", err)
|
||||||
|
tags = nil
|
||||||
|
}
|
||||||
|
|
||||||
u.render(w, "account_detail", AccountDetailData{
|
u.render(w, "account_detail", AccountDetailData{
|
||||||
PageData: PageData{CSRFToken: csrfToken},
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
Account: acct,
|
Account: acct,
|
||||||
@@ -150,6 +156,7 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
AllRoles: knownRoles,
|
AllRoles: knownRoles,
|
||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
PGCred: pgCred,
|
PGCred: pgCred,
|
||||||
|
Tags: tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
347
internal/ui/handlers_policy.go
Normal file
347
internal/ui/handlers_policy.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Policies page ----
|
||||||
|
|
||||||
|
// allActionStrings is the list of all policy action constants for the form UI.
|
||||||
|
var allActionStrings = []string{
|
||||||
|
string(policy.ActionListAccounts),
|
||||||
|
string(policy.ActionCreateAccount),
|
||||||
|
string(policy.ActionReadAccount),
|
||||||
|
string(policy.ActionUpdateAccount),
|
||||||
|
string(policy.ActionDeleteAccount),
|
||||||
|
string(policy.ActionReadRoles),
|
||||||
|
string(policy.ActionWriteRoles),
|
||||||
|
string(policy.ActionReadTags),
|
||||||
|
string(policy.ActionWriteTags),
|
||||||
|
string(policy.ActionIssueToken),
|
||||||
|
string(policy.ActionRevokeToken),
|
||||||
|
string(policy.ActionValidateToken),
|
||||||
|
string(policy.ActionRenewToken),
|
||||||
|
string(policy.ActionReadPGCreds),
|
||||||
|
string(policy.ActionWritePGCreds),
|
||||||
|
string(policy.ActionReadAudit),
|
||||||
|
string(policy.ActionEnrollTOTP),
|
||||||
|
string(policy.ActionRemoveTOTP),
|
||||||
|
string(policy.ActionLogin),
|
||||||
|
string(policy.ActionLogout),
|
||||||
|
string(policy.ActionListRules),
|
||||||
|
string(policy.ActionManageRules),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrfToken, err := u.setCSRFCookies(w)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := u.db.ListPolicyRules(false)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "failed to load policy rules")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]*PolicyRuleView, 0, len(rules))
|
||||||
|
for _, rec := range rules {
|
||||||
|
views = append(views, policyRuleToView(rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := PoliciesData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Rules: views,
|
||||||
|
AllActions: allActionStrings,
|
||||||
|
}
|
||||||
|
u.render(w, "policies", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// policyRuleToView converts a DB record to a template-friendly view.
|
||||||
|
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
||||||
|
pretty := prettyJSONStr(rec.RuleJSON)
|
||||||
|
return &PolicyRuleView{
|
||||||
|
ID: rec.ID,
|
||||||
|
Priority: rec.Priority,
|
||||||
|
Description: rec.Description,
|
||||||
|
RuleJSON: pretty,
|
||||||
|
Enabled: rec.Enabled,
|
||||||
|
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
|
||||||
|
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prettyJSONStr(s string) string {
|
||||||
|
var v json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreatePolicyRule handles POST /policies — creates a new policy rule.
|
||||||
|
func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
description := strings.TrimSpace(r.FormValue("description"))
|
||||||
|
if description == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "description is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priorityStr := r.FormValue("priority")
|
||||||
|
priority := 100
|
||||||
|
if priorityStr != "" {
|
||||||
|
p, err := strconv.Atoi(priorityStr)
|
||||||
|
if err != nil || p < 0 {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "priority must be a non-negative integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
priority = p
|
||||||
|
}
|
||||||
|
|
||||||
|
effectStr := r.FormValue("effect")
|
||||||
|
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := policy.RuleBody{
|
||||||
|
Effect: policy.Effect(effectStr),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-value fields.
|
||||||
|
if roles := r.Form["roles"]; len(roles) > 0 {
|
||||||
|
body.Roles = roles
|
||||||
|
}
|
||||||
|
if types := r.Form["account_types"]; len(types) > 0 {
|
||||||
|
body.AccountTypes = types
|
||||||
|
}
|
||||||
|
if actions := r.Form["actions"]; len(actions) > 0 {
|
||||||
|
acts := make([]policy.Action, len(actions))
|
||||||
|
for i, a := range actions {
|
||||||
|
acts[i] = policy.Action(a)
|
||||||
|
}
|
||||||
|
body.Actions = acts
|
||||||
|
}
|
||||||
|
if resType := r.FormValue("resource_type"); resType != "" {
|
||||||
|
body.ResourceType = policy.ResourceType(resType)
|
||||||
|
}
|
||||||
|
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
|
||||||
|
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
|
||||||
|
if svcNames := r.FormValue("service_names"); svcNames != "" {
|
||||||
|
body.ServiceNames = splitCommas(svcNames)
|
||||||
|
}
|
||||||
|
if tags := r.FormValue("required_tags"); tags != "" {
|
||||||
|
body.RequiredTags = splitCommas(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventPolicyRuleCreated, actorID, nil,
|
||||||
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||||
|
|
||||||
|
u.render(w, "policy_row", policyRuleToView(rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTogglePolicyRule handles PATCH /policies/{id}/enabled — enable or disable.
|
||||||
|
func (u *UIServer) handleTogglePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := u.loadPolicyRule(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledStr := r.FormValue("enabled")
|
||||||
|
enabled := enabledStr == "1" || enabledStr == "true"
|
||||||
|
|
||||||
|
if err := u.db.SetPolicyRuleEnabled(rec.ID, enabled); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "update failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
||||||
|
fmt.Sprintf(`{"rule_id":%d,"enabled":%v}`, rec.ID, enabled))
|
||||||
|
|
||||||
|
rec.Enabled = enabled
|
||||||
|
u.render(w, "policy_row", policyRuleToView(rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeletePolicyRule handles DELETE /policies/{id}.
|
||||||
|
func (u *UIServer) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := u.loadPolicyRule(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.DeletePolicyRule(rec.ID); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
||||||
|
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
||||||
|
|
||||||
|
// Return empty string to remove the row from the DOM.
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tag management ----
|
||||||
|
|
||||||
|
// handleSetAccountTags handles PUT /accounts/{id}/tags from the UI.
|
||||||
|
func (u *UIServer) handleSetAccountTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
acct, err := u.db.GetAccountByUUID(id)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsRaw := strings.TrimSpace(r.FormValue("tags_text"))
|
||||||
|
var tags []string
|
||||||
|
if tagsRaw != "" {
|
||||||
|
tags = splitLines(tagsRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: no empty tags.
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "tag values must not be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.SetAccountTags(acct.ID, tags); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "update failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
var actorID *int64
|
||||||
|
if claims != nil {
|
||||||
|
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
|
actorID = &actor.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.writeAudit(r, model.EventTagAdded, actorID, &acct.ID,
|
||||||
|
fmt.Sprintf(`{"account":%q,"tags":%d}`, acct.UUID, len(tags)))
|
||||||
|
|
||||||
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
|
u.render(w, "tags_editor", AccountDetailData{
|
||||||
|
PageData: PageData{CSRFToken: csrfToken},
|
||||||
|
Account: acct,
|
||||||
|
Tags: tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
func (u *UIServer) loadPolicyRule(w http.ResponseWriter, r *http.Request) (*model.PolicyRuleRecord, bool) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
if idStr == "" {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "rule id is required")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "rule id must be an integer")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rec, err := u.db.GetPolicyRule(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "policy rule not found")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitCommas splits a comma-separated string and trims whitespace from each element.
|
||||||
|
func splitCommas(s string) []string {
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitLines splits a newline-separated string and trims whitespace from each element.
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
parts := strings.Split(s, "\n")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -171,6 +171,9 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
"templates/fragments/error.html",
|
"templates/fragments/error.html",
|
||||||
"templates/fragments/audit_rows.html",
|
"templates/fragments/audit_rows.html",
|
||||||
"templates/fragments/pgcreds_form.html",
|
"templates/fragments/pgcreds_form.html",
|
||||||
|
"templates/fragments/tags_editor.html",
|
||||||
|
"templates/fragments/policy_row.html",
|
||||||
|
"templates/fragments/policy_form.html",
|
||||||
}
|
}
|
||||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,6 +189,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
"account_detail": "templates/account_detail.html",
|
"account_detail": "templates/account_detail.html",
|
||||||
"audit": "templates/audit.html",
|
"audit": "templates/audit.html",
|
||||||
"audit_detail": "templates/audit_detail.html",
|
"audit_detail": "templates/audit_detail.html",
|
||||||
|
"policies": "templates/policies.html",
|
||||||
}
|
}
|
||||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||||
for name, file := range pageFiles {
|
for name, file := range pageFiles {
|
||||||
@@ -263,6 +267,11 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
|
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
|
||||||
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
||||||
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
|
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
|
||||||
|
uiMux.Handle("GET /policies", adminGet(u.handlePoliciesPage))
|
||||||
|
uiMux.Handle("POST /policies", admin(u.handleCreatePolicyRule))
|
||||||
|
uiMux.Handle("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
|
||||||
|
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
|
||||||
|
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
||||||
|
|
||||||
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
||||||
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
||||||
@@ -509,6 +518,7 @@ type AccountDetailData struct {
|
|||||||
PageData
|
PageData
|
||||||
Roles []string
|
Roles []string
|
||||||
AllRoles []string
|
AllRoles []string
|
||||||
|
Tags []string
|
||||||
Tokens []*model.TokenRecord
|
Tokens []*model.TokenRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,3 +538,21 @@ type AuditDetailData struct {
|
|||||||
Event *db.AuditEventView
|
Event *db.AuditEventView
|
||||||
PageData
|
PageData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PolicyRuleView is a single policy rule prepared for template rendering.
|
||||||
|
type PolicyRuleView struct {
|
||||||
|
Description string
|
||||||
|
RuleJSON string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
ID int64
|
||||||
|
Priority int
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoliciesData is the view model for the policies list page.
|
||||||
|
type PoliciesData struct {
|
||||||
|
PageData
|
||||||
|
Rules []*PolicyRuleView
|
||||||
|
AllActions []string
|
||||||
|
}
|
||||||
|
|||||||
326
openapi.yaml
326
openapi.yaml
@@ -118,6 +118,103 @@ components:
|
|||||||
description: JSON blob with event-specific metadata. Never contains credentials.
|
description: JSON blob with event-specific metadata. Never contains credentials.
|
||||||
example: '{"jti":"f47ac10b-..."}'
|
example: '{"jti":"f47ac10b-..."}'
|
||||||
|
|
||||||
|
TagsResponse:
|
||||||
|
type: object
|
||||||
|
required: [tags]
|
||||||
|
properties:
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Current tag list for the account.
|
||||||
|
example: ["env:production", "svc:payments-api"]
|
||||||
|
|
||||||
|
RuleBody:
|
||||||
|
type: object
|
||||||
|
required: [effect]
|
||||||
|
description: |
|
||||||
|
The match conditions and effect of a policy rule. All fields except
|
||||||
|
`effect` are optional; an omitted field acts as a wildcard.
|
||||||
|
properties:
|
||||||
|
effect:
|
||||||
|
type: string
|
||||||
|
enum: [allow, deny]
|
||||||
|
example: allow
|
||||||
|
roles:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Subject must have at least one of these roles.
|
||||||
|
example: ["svc:payments-api"]
|
||||||
|
account_types:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum: [human, system]
|
||||||
|
description: Subject account type must be one of these.
|
||||||
|
example: ["system"]
|
||||||
|
subject_uuid:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Match only this specific subject UUID.
|
||||||
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
actions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
One of the defined action constants, e.g. `pgcreds:read`,
|
||||||
|
`accounts:list`. Subject action must be in this list.
|
||||||
|
example: ["pgcreds:read"]
|
||||||
|
resource_type:
|
||||||
|
type: string
|
||||||
|
description: Resource type the rule applies to.
|
||||||
|
example: pgcreds
|
||||||
|
owner_matches_subject:
|
||||||
|
type: boolean
|
||||||
|
description: Resource owner UUID must equal the subject UUID.
|
||||||
|
example: true
|
||||||
|
service_names:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Resource service name must be one of these.
|
||||||
|
example: ["payments-api"]
|
||||||
|
required_tags:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Resource must have ALL of these tags.
|
||||||
|
example: ["env:staging"]
|
||||||
|
|
||||||
|
PolicyRule:
|
||||||
|
type: object
|
||||||
|
required: [id, priority, description, rule, enabled, created_at, updated_at]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
priority:
|
||||||
|
type: integer
|
||||||
|
description: Lower number = evaluated first.
|
||||||
|
example: 100
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
example: Allow payments-api to read its own pgcreds
|
||||||
|
rule:
|
||||||
|
$ref: "#/components/schemas/RuleBody"
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: "2026-03-11T09:00:00Z"
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: "2026-03-11T09:00:00Z"
|
||||||
|
|
||||||
PGCreds:
|
PGCreds:
|
||||||
type: object
|
type: object
|
||||||
required: [host, port, database, username, password]
|
required: [host, port, database, username, password]
|
||||||
@@ -948,6 +1045,233 @@ paths:
|
|||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden"
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
|
||||||
|
/v1/accounts/{id}/tags:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
|
||||||
|
get:
|
||||||
|
summary: Get account tags (admin)
|
||||||
|
description: |
|
||||||
|
Return the current tag set for an account. Tags are used by the policy
|
||||||
|
engine for machine/service gating (e.g. `env:production`,
|
||||||
|
`svc:payments-api`).
|
||||||
|
operationId: getAccountTags
|
||||||
|
tags: [Admin — Policy]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Tag list.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TagsResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
put:
|
||||||
|
summary: Set account tags (admin)
|
||||||
|
description: |
|
||||||
|
Replace the account's full tag set atomically. Pass an empty array to
|
||||||
|
clear all tags. Changes take effect immediately for new policy
|
||||||
|
evaluations; no token renewal is required.
|
||||||
|
operationId: setAccountTags
|
||||||
|
tags: [Admin — Policy]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [tags]
|
||||||
|
properties:
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["env:production", "svc:payments-api"]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated tag list.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TagsResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/v1/policy/rules:
|
||||||
|
get:
|
||||||
|
summary: List policy rules (admin)
|
||||||
|
description: |
|
||||||
|
Return all operator-defined policy rules ordered by priority (ascending).
|
||||||
|
Built-in default rules (IDs -1 to -6) are not included.
|
||||||
|
operationId: listPolicyRules
|
||||||
|
tags: [Admin — Policy]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Array of policy rules.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/PolicyRule"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
|
||||||
|
post:
|
||||||
|
summary: Create policy rule (admin)
|
||||||
|
description: |
|
||||||
|
Create a new operator policy rule. Rules are evaluated in priority order
|
||||||
|
(lower number = evaluated first, default 100). Deny-wins: if any matching
|
||||||
|
rule has effect `deny`, access is denied regardless of allow rules.
|
||||||
|
operationId: createPolicyRule
|
||||||
|
tags: [Admin — Policy]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [description, rule]
|
||||||
|
properties:
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
example: Allow payments-api to read its own pgcreds
|
||||||
|
priority:
|
||||||
|
type: integer
|
||||||
|
description: Evaluation priority. Lower = first. Default 100.
|
||||||
|
example: 50
|
||||||
|
rule:
|
||||||
|
$ref: "#/components/schemas/RuleBody"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Rule created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PolicyRule"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
|
||||||
|
/v1/policy/rules/{id}:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
|
||||||
|
get:
|
||||||
|
summary: Get policy rule (admin)
|
||||||
|
operationId: getPolicyRule
|
||||||
|
tags: [Admin — Policy]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Policy rule.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PolicyRule"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
patch:
|
||||||
|
summary: Update policy rule (admin)
|
||||||
|
description: |
|
||||||
|
Update one or more fields of an existing policy rule. All fields are
|
||||||
|
optional; omitted fields are left unchanged.
|
||||||
|
operationId: updatePolicyRule
|
||||||
|
tags: [Admin — Policy]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
example: Updated description
|
||||||
|
priority:
|
||||||
|
type: integer
|
||||||
|
example: 75
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
|
rule:
|
||||||
|
$ref: "#/components/schemas/RuleBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated rule.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PolicyRule"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
delete:
|
||||||
|
summary: Delete policy rule (admin)
|
||||||
|
description: Permanently delete a policy rule. This action cannot be undone.
|
||||||
|
operationId: deletePolicyRule
|
||||||
|
tags: [Admin — Policy]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Rule deleted.
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
tags:
|
tags:
|
||||||
- name: Public
|
- name: Public
|
||||||
description: No authentication required.
|
description: No authentication required.
|
||||||
@@ -963,3 +1287,5 @@ tags:
|
|||||||
description: Requires admin role.
|
description: Requires admin role.
|
||||||
- name: Admin — Audit
|
- name: Admin — Audit
|
||||||
description: Requires admin role.
|
description: Requires admin role.
|
||||||
|
- name: Admin — Policy
|
||||||
|
description: Requires admin role. Manage policy rules and account tags.
|
||||||
|
|||||||
@@ -40,4 +40,8 @@
|
|||||||
{{template "pgcreds_form" .}}
|
{{template "pgcreds_form" .}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
|
||||||
|
<div id="tags-editor">{{template "tags_editor" .}}</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<li><a href="/dashboard">Dashboard</a></li>
|
<li><a href="/dashboard">Dashboard</a></li>
|
||||||
<li><a href="/accounts">Accounts</a></li>
|
<li><a href="/accounts">Accounts</a></li>
|
||||||
<li><a href="/audit">Audit</a></li>
|
<li><a href="/audit">Audit</a></li>
|
||||||
|
<li><a href="/policies">Policies</a></li>
|
||||||
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
77
web/templates/fragments/policy_form.html
Normal file
77
web/templates/fragments/policy_form.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{{define "policy_form"}}
|
||||||
|
<form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<input class="form-control" type="text" name="description"
|
||||||
|
placeholder="Description" required>
|
||||||
|
<input class="form-control" type="number" name="priority"
|
||||||
|
placeholder="100" value="100" min="0" max="9999">
|
||||||
|
<select class="form-control" name="effect" required>
|
||||||
|
<option value="allow">allow</option>
|
||||||
|
<option value="deny">deny</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Roles (select multiple)</label>
|
||||||
|
<select class="form-control" name="roles" multiple size="4" style="font-size:.85rem">
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="service">service</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Account types</label>
|
||||||
|
<select class="form-control" name="account_types" multiple size="4" style="font-size:.85rem">
|
||||||
|
<option value="human">human</option>
|
||||||
|
<option value="system">system</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:.5rem">
|
||||||
|
<label class="text-small text-muted">Actions (select multiple)</label>
|
||||||
|
<select class="form-control" name="actions" multiple size="5" style="font-family:monospace;font-size:.8rem">
|
||||||
|
{{range .AllActions}}
|
||||||
|
<option value="{{.}}">{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Resource type</label>
|
||||||
|
<select class="form-control" name="resource_type" style="font-size:.85rem">
|
||||||
|
<option value="">(any)</option>
|
||||||
|
<option value="account">account</option>
|
||||||
|
<option value="token">token</option>
|
||||||
|
<option value="pgcreds">pgcreds</option>
|
||||||
|
<option value="audit_log">audit_log</option>
|
||||||
|
<option value="totp">totp</option>
|
||||||
|
<option value="policy">policy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Subject UUID (optional)</label>
|
||||||
|
<input class="form-control" type="text" name="subject_uuid"
|
||||||
|
placeholder="Only match this account UUID" style="font-size:.85rem">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Service names (comma-separated)</label>
|
||||||
|
<input class="form-control" type="text" name="service_names"
|
||||||
|
placeholder="e.g. payments-api,billing" style="font-size:.85rem">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-small text-muted">Required tags (comma-separated)</label>
|
||||||
|
<input class="form-control" type="text" name="required_tags"
|
||||||
|
placeholder="e.g. env:production,svc:billing" style="font-size:.85rem">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:.5rem">
|
||||||
|
<label style="font-size:.85rem;display:flex;align-items:center;gap:.4rem;cursor:pointer">
|
||||||
|
<input type="checkbox" name="owner_matches_subject" value="1">
|
||||||
|
Owner must match subject (self-service rules only)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
42
web/templates/fragments/policy_row.html
Normal file
42
web/templates/fragments/policy_row.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{{define "policy_row"}}
|
||||||
|
<tr id="policy-row-{{.ID}}">
|
||||||
|
<td class="text-small text-muted">{{.ID}}</td>
|
||||||
|
<td class="text-small">{{.Priority}}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{.Description}}</strong>
|
||||||
|
<details style="margin-top:.25rem">
|
||||||
|
<summary class="text-small text-muted" style="cursor:pointer">Show rule JSON</summary>
|
||||||
|
<pre style="font-size:.75rem;background:#f8fafc;padding:.5rem;border-radius:4px;overflow:auto;margin-top:.25rem">{{.RuleJSON}}</pre>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
<td class="text-small">
|
||||||
|
{{/* Extract effect from RuleJSON via prettyJSON — displayed separately */}}
|
||||||
|
<span class="badge {{if .Enabled}}badge-active{{else}}badge-inactive{{end}}">
|
||||||
|
{{if .Enabled}}enabled{{else}}disabled{{end}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if .Enabled}}
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
hx-patch="/policies/{{.ID}}/enabled"
|
||||||
|
hx-vals='{"enabled":"0"}'
|
||||||
|
hx-target="#policy-row-{{.ID}}"
|
||||||
|
hx-swap="outerHTML">Disable</button>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
hx-patch="/policies/{{.ID}}/enabled"
|
||||||
|
hx-vals='{"enabled":"1"}'
|
||||||
|
hx-target="#policy-row-{{.ID}}"
|
||||||
|
hx-swap="outerHTML">Enable</button>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="text-small text-muted">{{.UpdatedAt}}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
hx-delete="/policies/{{.ID}}"
|
||||||
|
hx-target="#policy-row-{{.ID}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Delete policy rule {{.ID}}? This cannot be undone.">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
21
web/templates/fragments/tags_editor.html
Normal file
21
web/templates/fragments/tags_editor.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{{define "tags_editor"}}
|
||||||
|
<div id="tags-editor">
|
||||||
|
{{if .Tags}}
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.75rem">
|
||||||
|
{{range .Tags}}
|
||||||
|
<span style="font-size:.8rem;background:#f1f5f9;padding:.2rem .5rem;border-radius:3px;font-family:monospace">{{.}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted text-small" style="margin-bottom:.75rem">No tags assigned.</p>
|
||||||
|
{{end}}
|
||||||
|
<form hx-put="/accounts/{{.Account.UUID}}/tags"
|
||||||
|
hx-target="#tags-editor" hx-swap="outerHTML">
|
||||||
|
<textarea class="form-control" name="tags_text" rows="3"
|
||||||
|
style="font-family:monospace;font-size:.85rem;margin-bottom:.5rem"
|
||||||
|
placeholder="One tag per line, e.g. env:production svc:payments-api">{{range .Tags}}{{.}}
|
||||||
|
{{end}}</textarea>
|
||||||
|
<button class="btn btn-sm btn-secondary" type="submit">Save Tags</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
37
web/templates/policies.html
Normal file
37
web/templates/policies.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{{define "policies"}}{{template "base" .}}{{end}}
|
||||||
|
{{define "title"}}Policy Rules — MCIAS{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="page-header d-flex align-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1>Policy Rules</h1>
|
||||||
|
<p class="text-muted text-small">{{len .Rules}} operator rules (built-in defaults not shown)</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="document.getElementById('create-form').style.display='block';this.style.display='none'">
|
||||||
|
Add Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="create-form" class="card mt-2" style="display:none">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Create Policy Rule</h2>
|
||||||
|
{{template "policy_form" .}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrapper mt-2">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Effect</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="policies-tbody">
|
||||||
|
{{range .Rules}}{{template "policy_row" .}}{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user