Add policy-based authz and token delegation

- Replace requireAdmin (role-based) guards on all REST endpoints
  with RequirePolicy middleware backed by the existing policy engine;
  built-in admin wildcard rule (-1) preserves existing admin behaviour
  while operator rules can now grant targeted access to non-admin
  accounts (e.g. a system account allowed to list accounts)
- Wire policy engine into Server: loaded from DB at startup,
  reloaded after every policy-rule create/update/delete so changes
  take effect immediately without a server restart
- Add service_account_delegates table (migration 000008) so a human
  account can be delegated permission to issue tokens for a specific
  system account without holding the admin role
- Add token-download nonce mechanism: a short-lived (5 min),
  single-use random nonce is stored server-side after token issuance;
  the browser downloads the token as a file via
  GET /token/download/{nonce} (Content-Disposition: attachment)
  instead of copying from a flash message
- Add /service-accounts UI page for non-admin delegates
- Add TestPolicyEnforcement and TestPolicyDenyRule integration tests

Security:
- Policy engine uses deny-wins, default-deny semantics; admin wildcard
  is a compiled-in built-in and cannot be deleted via the API
- Token download nonces are 128-bit crypto/rand values, single-use,
  and expire after 5 minutes; a background goroutine evicts stale entries
- alg header validation and Ed25519 signing unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 14:40:16 -07:00
parent d6cc82755d
commit d4e8ef90ee
14 changed files with 1066 additions and 77 deletions

View File

@@ -2,10 +2,10 @@ package server
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha1" //nolint:gosec // G505: SHA1 required by RFC 6238 TOTP (HMAC-SHA1)
"encoding/binary"
"encoding/json"
"fmt"
@@ -23,6 +23,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
@@ -972,3 +973,149 @@ func TestTOTPMissingDoesNotIncrementLockout(t *testing.T) {
t.Error("account was locked after TOTP-missing login — lockout counter was incorrectly incremented (PEN-06)")
}
}
// issueSystemToken creates a system account, issues a JWT with the given roles,
// tracks it in the database, and returns the token string and account.
func issueSystemToken(t *testing.T, srv *Server, priv ed25519.PrivateKey, username string, roles []string) (string, *model.Account) {
t.Helper()
acct, err := srv.db.CreateAccount(username, model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("create system account: %v", err)
}
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, roles, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
return tokenStr, acct
}
// TestPolicyEnforcement verifies that the policy engine gates access:
// - Admin role is always allowed (built-in wildcard rule).
// - Unauthenticated requests are rejected.
// - Non-admin accounts are denied by default.
// - A non-admin account gains access once an operator policy rule is created.
// - Deleting the rule reverts to denial.
func TestPolicyEnforcement(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
handler := srv.Handler()
adminToken, _ := issueAdminToken(t, srv, priv, "admin-pol")
// 1. Admin can list accounts (built-in wildcard rule -1).
rr := doRequest(t, handler, "GET", "/v1/accounts", nil, adminToken)
if rr.Code != http.StatusOK {
t.Errorf("admin list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
// 2. Unauthenticated request is rejected.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("unauth list accounts status = %d, want 401", rr.Code)
}
// 3. System account with no operator rules is denied by default.
svcToken, svcAcct := issueSystemToken(t, srv, priv, "metacrypt", []string{"user"})
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (no policy) list accounts status = %d, want 403; body: %s", rr.Code, rr.Body.String())
}
// 4. Create an operator policy rule granting the system account accounts:list.
rule := createPolicyRuleRequest{
Description: "allow metacrypt to list accounts",
Priority: 50,
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
AccountTypes: []string{"system"},
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Allow,
},
}
rr = doRequest(t, handler, "POST", "/v1/policy/rules", rule, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create policy rule status = %d, want 201; body: %s", rr.Code, rr.Body.String())
}
var created policyRuleResponse
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
t.Fatalf("unmarshal created rule: %v", err)
}
// 5. The same system account can now list accounts.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusOK {
t.Errorf("system account (with policy) list accounts status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
// 6. The system account is still denied other actions (accounts:read).
rr = doRequest(t, handler, "POST", "/v1/accounts", map[string]string{
"username": "newuser", "password": "newpassword123", "account_type": "human",
}, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (list-only policy) create account status = %d, want 403", rr.Code)
}
// 7. Delete the rule and verify the account is denied again.
rr = doRequest(t, handler, "DELETE", fmt.Sprintf("/v1/policy/rules/%d", created.ID), nil, adminToken)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete policy rule status = %d, want 204; body: %s", rr.Code, rr.Body.String())
}
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("system account (rule deleted) list accounts status = %d, want 403", rr.Code)
}
}
// TestPolicyDenyRule verifies that an explicit Deny rule blocks access even
// when an Allow rule would otherwise permit it.
func TestPolicyDenyRule(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
handler := srv.Handler()
adminToken, _ := issueAdminToken(t, srv, priv, "admin-deny")
// Create an Allow rule for the system account.
svcToken, svcAcct := issueSystemToken(t, srv, priv, "svc-deny", []string{"user"})
allow := createPolicyRuleRequest{
Description: "allow svc-deny to list accounts",
Priority: 50,
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Allow,
},
}
rr := doRequest(t, handler, "POST", "/v1/policy/rules", allow, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create allow rule status = %d; body: %s", rr.Code, rr.Body.String())
}
// Verify access is granted.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusOK {
t.Fatalf("with allow rule, list accounts status = %d, want 200", rr.Code)
}
// Add a higher-priority Deny rule for the same account.
deny := createPolicyRuleRequest{
Description: "deny svc-deny accounts:list",
Priority: 10, // lower number = higher precedence
Rule: policy.RuleBody{
SubjectUUID: svcAcct.UUID,
Actions: []policy.Action{policy.ActionListAccounts},
Effect: policy.Deny,
},
}
rr = doRequest(t, handler, "POST", "/v1/policy/rules", deny, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create deny rule status = %d; body: %s", rr.Code, rr.Body.String())
}
// Deny-wins: access must now be blocked despite the Allow rule.
rr = doRequest(t, handler, "GET", "/v1/accounts", nil, svcToken)
if rr.Code != http.StatusForbidden {
t.Errorf("deny-wins: list accounts status = %d, want 403", rr.Code)
}
}