diff --git a/.junie/memory/errors.md b/.junie/memory/errors.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/memory/feedback.md b/.junie/memory/feedback.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/memory/language.json b/.junie/memory/language.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.junie/memory/language.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.junie/memory/memory.version b/.junie/memory/memory.version new file mode 100644 index 0000000..9f8e9b6 --- /dev/null +++ b/.junie/memory/memory.version @@ -0,0 +1 @@ +1.0 \ No newline at end of file diff --git a/.junie/memory/tasks.md b/.junie/memory/tasks.md new file mode 100644 index 0000000..e69de29 diff --git a/PROGRESS.md b/PROGRESS.md index 6bf7860..81e1fbb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,6 +4,108 @@ Source of truth for current development state. --- All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean. +### 2026-03-12 — UI: pgcreds create button; show logged-in user + +**web/templates/pgcreds.html** +- "New Credentials" card is now always rendered; an "Add Credentials" toggle + button reveals the create form (hidden by default). When all system accounts + already have credentials, a message is shown instead of the form. Previously + the entire card was hidden when `UncredentialedAccounts` was empty. + +**internal/ui/ui.go** +- Added `ActorName string` field to `PageData` (embedded in every page view struct) +- Added `actorName(r *http.Request) string` helper — resolves username from JWT + claims via a DB lookup; returns `""` if unauthenticated + +**internal/ui/handlers_{accounts,audit,dashboard,policy}.go** +- All full-page `PageData` constructors now pass `ActorName: u.actorName(r)` + +**web/templates/base.html** +- Nav bar renders the actor's username as a muted label immediately before the + Logout button when logged in + +**web/static/style.css** +- Added `.nav-actor` rule (muted grey, 0.85rem) for the username label + +All tests pass (`go test ./...`); `golangci-lint run ./...` clean. + +### 2026-03-12 — PG credentials create form on /pgcreds page + +**internal/ui/handlers_accounts.go** +- `handlePGCredsList`: extended to build `UncredentialedAccounts` — system + accounts that have no credentials yet, passed to the template for the create + form; filters from `ListAccounts()` by type and excludes accounts already in + the accessible-credentials set +- `handleCreatePGCreds`: `POST /pgcreds` — validates selected account UUID + (must be a system account), host, port, database, username, password; + encrypts password with AES-256-GCM; calls `WritePGCredentials` then + `SetPGCredentialOwner`; writes `EventPGCredUpdated` audit event; redirects + to `GET /pgcreds` on success + +**internal/ui/ui.go** +- Registered `POST /pgcreds` route +- Added `UncredentialedAccounts []*model.Account` field to `PGCredsData` + +**web/templates/pgcreds.html** +- New "New Credentials" card shown when `UncredentialedAccounts` is non-empty; + contains a plain POST form (no HTMX, redirect on success) with: + - Service Account dropdown populated from `UncredentialedAccounts` + - Host / Port / Database / Username / Password inputs + - CSRF token hidden field + +All tests pass (`go test ./...`); `golangci-lint run ./...` clean. + +### 2026-03-12 — PG credentials access grants UI + +**internal/ui/handlers_accounts.go** +- `handleGrantPGCredAccess`: `POST /accounts/{id}/pgcreds/access` — grants a + nominated account read access to the credential set; ownership enforced + server-side by comparing stored `owner_id` with the logged-in actor; + grantee resolved via UUID lookup (not raw ID); writes + `EventPGCredAccessGranted` audit event; re-renders `pgcreds_form` fragment +- `handleRevokePGCredAccess`: `DELETE /accounts/{id}/pgcreds/access/{grantee}` + — removes a specific grantee's read access; same ownership check as grant; + writes `EventPGCredAccessRevoked` audit event; re-renders fragment +- `handlePGCredsList`: `GET /pgcreds` — lists all pg_credentials accessible to + the currently logged-in user (owned + explicitly granted) + +**internal/ui/ui.go** +- Registered three new routes: `POST /accounts/{id}/pgcreds/access`, + `DELETE /accounts/{id}/pgcreds/access/{grantee}`, `GET /pgcreds` +- Added `pgcreds` to the page template map (renders `pgcreds.html`) +- Added `isPGCredOwner(*int64, *model.PGCredential) bool` template function + — nil-safe ownership check used in `pgcreds_form` to gate owner-only controls +- Added `derefInt64(*int64) int64` template function (nil-safe dereference) + +**internal/model/model.go** +- Added `ServiceAccountUUID string` field to `PGCredential` — populated by + list queries so the PG creds list page can link to the account detail page + +**internal/db/pgcred_access.go** +- `ListAccessiblePGCreds`: extended SELECT to also fetch `a.uuid`; updated + `scanPGCredWithUsername` to populate `ServiceAccountUUID` + +**web/templates/fragments/pgcreds_form.html** +- Owner sees a collapsible "Update credentials" `
` block; non-owners + and grantees see metadata read-only +- Non-owners who haven't yet created a credential see the full create form + (first save sets them as owner) +- New "Access Grants" section below the credential metadata: + - Table listing all grantees with username and grant timestamp + - Revoke button (DELETE HTMX, `hx-confirm`) — owner only + - "Grant Access" dropdown form (POST HTMX) — owner only, populated with + all accounts + +**web/templates/pgcreds.html** (new page) +- Lists all accessible credentials in a table: service account, host:port, + database, username, updated-at, link to account detail page +- Falls back to "No Postgres credentials accessible" when list is empty + +**web/templates/base.html** +- Added "PG Creds" nav link pointing to `/pgcreds` + +All tests pass (`go test ./...`); `golangci-lint run ./...` clean. + ### 2026-03-11 — Postgres Credentials UI + Policy/Tags UI completion **internal/ui/** diff --git a/internal/db/accounts.go b/internal/db/accounts.go index b83c761..e7324cf 100644 --- a/internal/db/accounts.go +++ b/internal/db/accounts.go @@ -450,16 +450,17 @@ func (db *DB) WritePGCredentials(accountID int64, host string, port int, dbName, func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) { var cred model.PGCredential var createdAtStr, updatedAtStr string + var ownerID sql.NullInt64 err := db.sql.QueryRow(` SELECT id, account_id, pg_host, pg_port, pg_database, pg_username, - pg_password_enc, pg_password_nonce, created_at, updated_at + pg_password_enc, pg_password_nonce, created_at, updated_at, owner_id FROM pg_credentials WHERE account_id = ? `, accountID).Scan( &cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort, &cred.PGDatabase, &cred.PGUsername, &cred.PGPasswordEnc, &cred.PGPasswordNonce, - &createdAtStr, &updatedAtStr, + &createdAtStr, &updatedAtStr, &ownerID, ) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -476,6 +477,10 @@ func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) { if err != nil { return nil, err } + if ownerID.Valid { + v := ownerID.Int64 + cred.OwnerID = &v + } return &cred, nil } diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 43aa540..961ed6f 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -162,6 +162,35 @@ CREATE TABLE IF NOT EXISTS policy_rules ( 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')) ); +`, + }, + { + id: 5, + sql: ` +-- Track which accounts own each set of pg_credentials and which other +-- accounts have been granted read access to them. +-- +-- owner_id: the account that administers the credentials and may grant/revoke +-- access. Defaults to the system account itself. This column is +-- nullable so that rows created before migration 5 are not broken. +ALTER TABLE pg_credentials ADD COLUMN owner_id INTEGER REFERENCES accounts(id); + +-- pg_credential_access records an explicit "all-or-nothing" read grant from +-- the credential owner to another account. Grantees may view connection +-- metadata (host, port, database, username) but the password is never +-- decrypted for them in the UI. Only the owner may update or delete the +-- credential set. +CREATE TABLE IF NOT EXISTS pg_credential_access ( + id INTEGER PRIMARY KEY, + credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE, + grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + granted_by INTEGER REFERENCES accounts(id), + granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE (credential_id, grantee_id) +); + +CREATE INDEX IF NOT EXISTS idx_pgcred_access_cred ON pg_credential_access (credential_id); +CREATE INDEX IF NOT EXISTS idx_pgcred_access_grantee ON pg_credential_access (grantee_id); `, }, } diff --git a/internal/db/pgcred_access.go b/internal/db/pgcred_access.go new file mode 100644 index 0000000..6b6acd1 --- /dev/null +++ b/internal/db/pgcred_access.go @@ -0,0 +1,247 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "git.wntrmute.dev/kyle/mcias/internal/model" +) + +// ListCredentialedAccountIDs returns the set of account IDs that already have +// a pg_credentials row. Used to filter the "uncredentialed system accounts" +// list on the /pgcreds create form without leaking credential content. +func (db *DB) ListCredentialedAccountIDs() (map[int64]struct{}, error) { + rows, err := db.sql.Query(`SELECT account_id FROM pg_credentials`) + if err != nil { + return nil, fmt.Errorf("db: list credentialed account ids: %w", err) + } + defer func() { _ = rows.Close() }() + + ids := make(map[int64]struct{}) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("db: scan credentialed account id: %w", err) + } + ids[id] = struct{}{} + } + return ids, rows.Err() +} + +// SetPGCredentialOwner records the owning account for a pg_credentials row. +// This is called on first write so that pre-migration rows retain a nil owner. +// It is idempotent: if the owner is already set it is overwritten. +func (db *DB) SetPGCredentialOwner(credentialID, ownerID int64) error { + _, err := db.sql.Exec(` + UPDATE pg_credentials SET owner_id = ? WHERE id = ? + `, ownerID, credentialID) + if err != nil { + return fmt.Errorf("db: set pg credential owner: %w", err) + } + return nil +} + +// GetPGCredentialByID retrieves a single pg_credentials row by its primary key. +// Returns ErrNotFound if no such credential exists. +func (db *DB) GetPGCredentialByID(id int64) (*model.PGCredential, error) { + var cred model.PGCredential + var createdAtStr, updatedAtStr string + var ownerID sql.NullInt64 + + err := db.sql.QueryRow(` + SELECT p.id, p.account_id, p.pg_host, p.pg_port, p.pg_database, p.pg_username, + p.pg_password_enc, p.pg_password_nonce, p.created_at, p.updated_at, p.owner_id + FROM pg_credentials p WHERE p.id = ? + `, id).Scan( + &cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort, + &cred.PGDatabase, &cred.PGUsername, + &cred.PGPasswordEnc, &cred.PGPasswordNonce, + &createdAtStr, &updatedAtStr, &ownerID, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("db: get pg credential by id: %w", err) + } + + cred.CreatedAt, err = parseTime(createdAtStr) + if err != nil { + return nil, err + } + cred.UpdatedAt, err = parseTime(updatedAtStr) + if err != nil { + return nil, err + } + if ownerID.Valid { + v := ownerID.Int64 + cred.OwnerID = &v + } + return &cred, nil +} + +// GrantPGCredAccess grants an account read access to a pg_credentials set. +// If the grant already exists the call is a no-op (UNIQUE constraint). +// grantedBy may be nil if the grant is made programmatically. +func (db *DB) GrantPGCredAccess(credentialID, granteeID int64, grantedBy *int64) error { + n := now() + _, err := db.sql.Exec(` + INSERT INTO pg_credential_access (credential_id, grantee_id, granted_by, granted_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(credential_id, grantee_id) DO NOTHING + `, credentialID, granteeID, grantedBy, n) + if err != nil { + return fmt.Errorf("db: grant pg cred access: %w", err) + } + return nil +} + +// RevokePGCredAccess removes a grantee's access to a pg_credentials set. +func (db *DB) RevokePGCredAccess(credentialID, granteeID int64) error { + _, err := db.sql.Exec(` + DELETE FROM pg_credential_access WHERE credential_id = ? AND grantee_id = ? + `, credentialID, granteeID) + if err != nil { + return fmt.Errorf("db: revoke pg cred access: %w", err) + } + return nil +} + +// ListPGCredAccess returns all access grants for a pg_credentials set, +// joining against accounts to populate grantee username and UUID. +func (db *DB) ListPGCredAccess(credentialID int64) ([]*model.PGCredAccessGrant, error) { + rows, err := db.sql.Query(` + SELECT pca.id, pca.credential_id, pca.grantee_id, pca.granted_by, pca.granted_at, + a.uuid, a.username + FROM pg_credential_access pca + JOIN accounts a ON a.id = pca.grantee_id + WHERE pca.credential_id = ? + ORDER BY pca.granted_at ASC + `, credentialID) + if err != nil { + return nil, fmt.Errorf("db: list pg cred access: %w", err) + } + defer func() { _ = rows.Close() }() + + var grants []*model.PGCredAccessGrant + for rows.Next() { + g, err := scanPGCredAccessGrant(rows) + if err != nil { + return nil, err + } + grants = append(grants, g) + } + return grants, rows.Err() +} + +// CheckPGCredAccess reports whether accountID has an explicit access grant for +// credentialID. The credential owner always has access implicitly; callers +// must check ownership separately. +func (db *DB) CheckPGCredAccess(credentialID, accountID int64) (bool, error) { + var count int + err := db.sql.QueryRow(` + SELECT COUNT(*) FROM pg_credential_access + WHERE credential_id = ? AND grantee_id = ? + `, credentialID, accountID).Scan(&count) + if err != nil { + return false, fmt.Errorf("db: check pg cred access: %w", err) + } + return count > 0, nil +} + +// PGCredWithAccount extends PGCredential with the owning system account's +// username, used for the "My PG Credentials" listing view. +type PGCredWithAccount struct { + model.PGCredential +} + +// ListAccessiblePGCreds returns all pg_credentials rows that accountID may +// view: those where accountID is the owner, plus those where an explicit +// access grant exists. The ServiceUsername and ServiceAccountUUID fields are +// populated from the owning system account for display and navigation. +func (db *DB) ListAccessiblePGCreds(accountID int64) ([]*model.PGCredential, error) { + rows, err := db.sql.Query(` + SELECT p.id, p.account_id, p.pg_host, p.pg_port, p.pg_database, p.pg_username, + p.pg_password_enc, p.pg_password_nonce, p.created_at, p.updated_at, p.owner_id, + a.username, a.uuid + FROM pg_credentials p + JOIN accounts a ON a.id = p.account_id + WHERE p.owner_id = ? + OR EXISTS ( + SELECT 1 FROM pg_credential_access pca + WHERE pca.credential_id = p.id AND pca.grantee_id = ? + ) + ORDER BY a.username ASC + `, accountID, accountID) + if err != nil { + return nil, fmt.Errorf("db: list accessible pg creds: %w", err) + } + defer func() { _ = rows.Close() }() + + var creds []*model.PGCredential + for rows.Next() { + cred, err := scanPGCredWithUsername(rows) + if err != nil { + return nil, err + } + creds = append(creds, cred) + } + return creds, rows.Err() +} + +func scanPGCredWithUsername(rows *sql.Rows) (*model.PGCredential, error) { + var cred model.PGCredential + var createdAtStr, updatedAtStr string + var ownerID sql.NullInt64 + + err := rows.Scan( + &cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort, + &cred.PGDatabase, &cred.PGUsername, + &cred.PGPasswordEnc, &cred.PGPasswordNonce, + &createdAtStr, &updatedAtStr, &ownerID, + &cred.ServiceUsername, &cred.ServiceAccountUUID, + ) + if err != nil { + return nil, fmt.Errorf("db: scan pg cred with username: %w", err) + } + + cred.CreatedAt, err = parseTime(createdAtStr) + if err != nil { + return nil, err + } + cred.UpdatedAt, err = parseTime(updatedAtStr) + if err != nil { + return nil, err + } + if ownerID.Valid { + v := ownerID.Int64 + cred.OwnerID = &v + } + return &cred, nil +} + +func scanPGCredAccessGrant(rows *sql.Rows) (*model.PGCredAccessGrant, error) { + var g model.PGCredAccessGrant + var grantedAtStr string + var grantedBy sql.NullInt64 + + err := rows.Scan( + &g.ID, &g.CredentialID, &g.GranteeID, &grantedBy, &grantedAtStr, + &g.GranteeUUID, &g.GranteeName, + ) + if err != nil { + return nil, fmt.Errorf("db: scan pg cred access grant: %w", err) + } + + g.GrantedAt, err = time.Parse("2006-01-02T15:04:05Z", grantedAtStr) + if err != nil { + return nil, fmt.Errorf("db: parse pg cred access grant time %q: %w", grantedAtStr, err) + } + if grantedBy.Valid { + v := grantedBy.Int64 + g.GrantedBy = &v + } + return &g, nil +} diff --git a/internal/model/model.go b/internal/model/model.go index 8959c4c..17f823c 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -87,18 +87,26 @@ type SystemToken struct { // PGCredential holds Postgres connection details for a system account. // The password is encrypted at rest; PGPassword is only populated after // decryption and must never be logged or included in API responses. +// +// OwnerID identifies the account permitted to update, delete, and manage +// access grants for this credential set. A nil OwnerID means the credential +// pre-dates ownership tracking; for backwards compatibility, nil is treated as +// unowned (only admins can manage it via the UI). type PGCredential struct { - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - PGHost string `json:"host"` - PGDatabase string `json:"database"` - PGUsername string `json:"username"` - PGPassword string `json:"-"` - PGPasswordEnc []byte `json:"-"` - PGPasswordNonce []byte `json:"-"` - ID int64 `json:"-"` - AccountID int64 `json:"-"` - PGPort int `json:"port"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + OwnerID *int64 `json:"-"` + ServiceAccountUUID string `json:"service_account_uuid,omitempty"` + PGUsername string `json:"username"` + PGPassword string `json:"-"` + ServiceUsername string `json:"service_username,omitempty"` + PGDatabase string `json:"database"` + PGHost string `json:"host"` + PGPasswordEnc []byte `json:"-"` + PGPasswordNonce []byte `json:"-"` + ID int64 `json:"-"` + AccountID int64 `json:"-"` + PGPort int `json:"port"` } // AuditEvent represents a single entry in the append-only audit log. @@ -141,6 +149,26 @@ const ( EventPolicyDeny = "policy_deny" ) +// PGCredAccessGrant records that a specific account has been granted read +// access to a pg_credentials set. Only the credential owner can manage +// grants; grantees can view connection metadata but never the plaintext +// password, and they cannot update or delete the credential set. +type PGCredAccessGrant struct { + GrantedAt time.Time `json:"granted_at"` + GrantedBy *int64 `json:"-"` + GranteeUUID string `json:"grantee_id"` + GranteeName string `json:"grantee_username"` + ID int64 `json:"-"` + CredentialID int64 `json:"-"` + GranteeID int64 `json:"-"` +} + +// Audit event type for pg_credential_access changes. +const ( + EventPGCredAccessGranted = "pgcred_access_granted" //nolint:gosec // G101: audit event type, not a credential + EventPGCredAccessRevoked = "pgcred_access_revoked" //nolint:gosec // G101: audit event type, not a credential +) + // 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. diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go index e9a27ac..bc346fd 100644 --- a/internal/ui/handlers_accounts.go +++ b/internal/ui/handlers_accounts.go @@ -32,7 +32,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) { } u.render(w, "accounts", AccountsData{ - PageData: PageData{CSRFToken: csrfToken}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, Accounts: accounts, }) } @@ -132,15 +132,41 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) { tokens = nil } + // Resolve the currently logged-in actor. + claims := claimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil { + actorID = &actor.ID + } + } + // Load PG credentials for system accounts only; leave nil for human accounts // and when no credentials have been stored yet. var pgCred *model.PGCredential + var pgCredGrants []*model.PGCredAccessGrant + var grantableAccounts []*model.Account if acct.AccountType == model.AccountTypeSystem { pgCred, err = u.db.ReadPGCredentials(acct.ID) if err != nil && !errors.Is(err, db.ErrNotFound) { u.logger.Warn("read pg credentials", "error", err) } // ErrNotFound is expected when no credentials have been stored yet. + + // Load access grants; only show management controls when the actor is owner. + if pgCred != nil { + pgCredGrants, err = u.db.ListPGCredAccess(pgCred.ID) + if err != nil { + u.logger.Warn("list pg cred access", "error", err) + } + // Populate the "add grantee" dropdown only for the credential owner. + if actorID != nil && pgCred.OwnerID != nil && *pgCred.OwnerID == *actorID { + grantableAccounts, err = u.db.ListAccounts() + if err != nil { + u.logger.Warn("list accounts for pgcred grant", "error", err) + } + } + } } tags, err := u.db.GetAccountTags(acct.ID) @@ -150,13 +176,16 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) { } u.render(w, "account_detail", AccountDetailData{ - PageData: PageData{CSRFToken: csrfToken}, - Account: acct, - Roles: roles, - AllRoles: knownRoles, - Tokens: tokens, - PGCred: pgCred, - Tags: tags, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, + Account: acct, + Roles: roles, + AllRoles: knownRoles, + Tokens: tokens, + PGCred: pgCred, + PGCredGrants: pgCredGrants, + GrantableAccounts: grantableAccounts, + ActorID: actorID, + Tags: tags, }) } @@ -456,18 +485,417 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) { pgCred = nil } + // Security: set the credential owner to the actor on first write so that + // subsequent grant/revoke operations can enforce ownership. If no actor + // is present (e.g. bootstrap), the owner remains nil. + if pgCred != nil && pgCred.OwnerID == nil && actorID != nil { + if err := u.db.SetPGCredentialOwner(pgCred.ID, *actorID); err != nil { + u.logger.Warn("set pg credential owner", "error", err) + } else { + pgCred.OwnerID = actorID + } + } + + // Load existing access grants to re-render the full section. + var grants []*model.PGCredAccessGrant + if pgCred != nil { + grants, err = u.db.ListPGCredAccess(pgCred.ID) + if err != nil { + u.logger.Warn("list pg cred access after write", "error", err) + } + } + + // Load non-system accounts available to grant access to. + grantableAccounts, err := u.db.ListAccounts() + if err != nil { + u.logger.Warn("list accounts for pgcred grant", "error", err) + } + csrfToken, err := u.setCSRFCookies(w) if err != nil { csrfToken = "" } u.render(w, "pgcreds_form", AccountDetailData{ - PageData: PageData{CSRFToken: csrfToken}, - Account: acct, - PGCred: pgCred, + PageData: PageData{CSRFToken: csrfToken}, + Account: acct, + PGCred: pgCred, + PGCredGrants: grants, + GrantableAccounts: grantableAccounts, + ActorID: actorID, }) } +// handleGrantPGCredAccess grants another account read access to a pg_credentials +// set owned by the actor. Only the credential owner may grant access; this is +// enforced by comparing the stored owner_id with the logged-in actor. +// +// Security: ownership is re-verified server-side on every request; the form +// field grantee_uuid is looked up in the accounts table (no ID injection). +// Audit event EventPGCredAccessGranted is recorded on success. +func (u *UIServer) handleGrantPGCredAccess(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 + } + + id := r.PathValue("id") + acct, err := u.db.GetAccountByUUID(id) + if err != nil { + u.renderError(w, r, http.StatusNotFound, "account not found") + return + } + if acct.AccountType != model.AccountTypeSystem { + u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts") + return + } + + pgCred, err := u.db.ReadPGCredentials(acct.ID) + if err != nil { + u.renderError(w, r, http.StatusNotFound, "no credentials stored for this account") + return + } + + // Resolve the currently logged-in actor. + claims := claimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil { + actorID = &actor.ID + } + } + + // Security: only the credential owner may grant access. + if actorID == nil || pgCred.OwnerID == nil || *pgCred.OwnerID != *actorID { + u.renderError(w, r, http.StatusForbidden, "only the credential owner may grant access") + return + } + + granteeUUID := strings.TrimSpace(r.FormValue("grantee_uuid")) + if granteeUUID == "" { + u.renderError(w, r, http.StatusBadRequest, "grantee is required") + return + } + grantee, err := u.db.GetAccountByUUID(granteeUUID) + if err != nil { + u.renderError(w, r, http.StatusNotFound, "grantee account not found") + return + } + + if err := u.db.GrantPGCredAccess(pgCred.ID, grantee.ID, actorID); err != nil { + u.logger.Error("grant pg cred access", "error", err) + u.renderError(w, r, http.StatusInternalServerError, "failed to grant access") + return + } + + u.writeAudit(r, model.EventPGCredAccessGranted, actorID, &grantee.ID, + fmt.Sprintf(`{"credential_id":%d,"grantee":%q}`, pgCred.ID, grantee.UUID)) + + // If the caller requested a redirect (e.g. from the /pgcreds page), honour it. + if next := r.FormValue("_next"); next == "/pgcreds" { + http.Redirect(w, r, "/pgcreds", http.StatusSeeOther) + return + } + + // Re-render the full pgcreds section so the new grant appears. + grants, err := u.db.ListPGCredAccess(pgCred.ID) + if err != nil { + u.logger.Warn("list pg cred access after grant", "error", err) + } + grantableAccounts, err := u.db.ListAccounts() + if err != nil { + u.logger.Warn("list accounts for pgcred grant", "error", err) + } + csrfToken, err := u.setCSRFCookies(w) + if err != nil { + csrfToken = "" + } + u.render(w, "pgcreds_form", AccountDetailData{ + PageData: PageData{CSRFToken: csrfToken}, + Account: acct, + PGCred: pgCred, + PGCredGrants: grants, + GrantableAccounts: grantableAccounts, + ActorID: actorID, + }) +} + +// handleRevokePGCredAccess removes a grantee's read access to a pg_credentials set. +// Only the credential owner may revoke grants; this is enforced server-side. +// +// Security: ownership re-verified on every request. grantee_uuid looked up +// in accounts table — not taken from URL path to prevent enumeration. +// Audit event EventPGCredAccessRevoked is recorded on success. +func (u *UIServer) handleRevokePGCredAccess(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 + } + + id := r.PathValue("id") + acct, err := u.db.GetAccountByUUID(id) + if err != nil { + u.renderError(w, r, http.StatusNotFound, "account not found") + return + } + if acct.AccountType != model.AccountTypeSystem { + u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts") + return + } + + pgCred, err := u.db.ReadPGCredentials(acct.ID) + if err != nil { + u.renderError(w, r, http.StatusNotFound, "no credentials stored for this account") + return + } + + // Resolve the currently logged-in actor. + claims := claimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil { + actorID = &actor.ID + } + } + + // Security: only the credential owner may revoke access. + if actorID == nil || pgCred.OwnerID == nil || *pgCred.OwnerID != *actorID { + u.renderError(w, r, http.StatusForbidden, "only the credential owner may revoke access") + return + } + + granteeUUID := r.PathValue("grantee") + if granteeUUID == "" { + u.renderError(w, r, http.StatusBadRequest, "grantee is required") + return + } + grantee, err := u.db.GetAccountByUUID(granteeUUID) + if err != nil { + u.renderError(w, r, http.StatusNotFound, "grantee account not found") + return + } + + if err := u.db.RevokePGCredAccess(pgCred.ID, grantee.ID); err != nil { + u.logger.Error("revoke pg cred access", "error", err) + u.renderError(w, r, http.StatusInternalServerError, "failed to revoke access") + return + } + + u.writeAudit(r, model.EventPGCredAccessRevoked, actorID, &grantee.ID, + fmt.Sprintf(`{"credential_id":%d,"grantee":%q}`, pgCred.ID, grantee.UUID)) + + // If the caller requested a redirect (e.g. from the /pgcreds page), honour it. + if r.URL.Query().Get("_next") == "/pgcreds" { + if isHTMX(r) { + w.Header().Set("HX-Redirect", "/pgcreds") + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, "/pgcreds", http.StatusSeeOther) + return + } + + // Re-render the full pgcreds section with the grant removed. + grants, err := u.db.ListPGCredAccess(pgCred.ID) + if err != nil { + u.logger.Warn("list pg cred access after revoke", "error", err) + } + grantableAccounts, err := u.db.ListAccounts() + if err != nil { + u.logger.Warn("list accounts for pgcred grant", "error", err) + } + csrfToken, err := u.setCSRFCookies(w) + if err != nil { + csrfToken = "" + } + u.render(w, "pgcreds_form", AccountDetailData{ + PageData: PageData{CSRFToken: csrfToken}, + Account: acct, + PGCred: pgCred, + PGCredGrants: grants, + GrantableAccounts: grantableAccounts, + ActorID: actorID, + }) +} + +// handlePGCredsList renders the "My PG Credentials" page, showing all +// pg_credentials accessible to the currently logged-in user (owned + granted), +// plus a create form for system accounts that have no credentials yet. +func (u *UIServer) handlePGCredsList(w http.ResponseWriter, r *http.Request) { + csrfToken, err := u.setCSRFCookies(w) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + claims := claimsFromContext(r.Context()) + if claims == nil { + u.redirectToLogin(w, r) + return + } + actor, err := u.db.GetAccountByUUID(claims.Subject) + if err != nil { + u.renderError(w, r, http.StatusInternalServerError, "could not resolve actor") + return + } + + creds, err := u.db.ListAccessiblePGCreds(actor.ID) + if err != nil { + u.renderError(w, r, http.StatusInternalServerError, "failed to load credentials") + return + } + + // Build the list of system accounts that have no credentials at all + // (not just those absent from this actor's accessible set) so the + // create form remains available even when the actor has no existing creds. + credAcctIDs, err := u.db.ListCredentialedAccountIDs() + if err != nil { + u.logger.Warn("list credentialed account ids", "error", err) + credAcctIDs = map[int64]struct{}{} + } + allAccounts, err := u.db.ListAccounts() + if err != nil { + u.logger.Warn("list accounts for pgcreds create form", "error", err) + } + var uncredentialed []*model.Account + for _, a := range allAccounts { + if a.AccountType == model.AccountTypeSystem { + if _, hasCredential := credAcctIDs[a.ID]; !hasCredential { + uncredentialed = append(uncredentialed, a) + } + } + } + + // For each credential owned by the actor, load its access grants so the + // /pgcreds page can render inline grant management controls. + credGrants := make(map[int64][]*model.PGCredAccessGrant) + for _, c := range creds { + if c.OwnerID != nil && *c.OwnerID == actor.ID { + grants, err := u.db.ListPGCredAccess(c.ID) + if err != nil { + u.logger.Warn("list pg cred access for owned cred", "cred_id", c.ID, "error", err) + continue + } + credGrants[c.ID] = grants + } + } + + u.render(w, "pgcreds", PGCredsData{ + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, + Creds: creds, + UncredentialedAccounts: uncredentialed, + CredGrants: credGrants, + AllAccounts: allAccounts, + ActorID: &actor.ID, + }) +} + +// handleCreatePGCreds creates a new PG credential set from the /pgcreds page. +// The submitter selects a system account from the uncredentialed list and +// provides connection details; on success they become the credential owner. +// +// Security: only system accounts may hold PG credentials; the submitted account +// UUID is validated server-side. Password is encrypted with AES-256-GCM before +// storage; the plaintext is never logged or included in any response. +// Audit event EventPGCredUpdated is recorded on success. +func (u *UIServer) handleCreatePGCreds(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 + } + + accountUUID := strings.TrimSpace(r.FormValue("account_uuid")) + if accountUUID == "" { + u.renderError(w, r, http.StatusBadRequest, "account is required") + return + } + acct, err := u.db.GetAccountByUUID(accountUUID) + if err != nil { + u.renderError(w, r, http.StatusNotFound, "account not found") + return + } + if acct.AccountType != model.AccountTypeSystem { + u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts") + return + } + + host := strings.TrimSpace(r.FormValue("host")) + portStr := strings.TrimSpace(r.FormValue("port")) + dbName := strings.TrimSpace(r.FormValue("database")) + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + + if host == "" { + u.renderError(w, r, http.StatusBadRequest, "host is required") + return + } + if dbName == "" { + u.renderError(w, r, http.StatusBadRequest, "database is required") + return + } + if username == "" { + u.renderError(w, r, http.StatusBadRequest, "username is required") + return + } + // Security: password is required on every write — the UI never carries an + // existing password, so callers must supply it explicitly. + if password == "" { + u.renderError(w, r, http.StatusBadRequest, "password is required") + return + } + + port := 5432 + if portStr != "" { + port, err = strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + u.renderError(w, r, http.StatusBadRequest, "port must be an integer between 1 and 65535") + return + } + } + + // Security: encrypt with AES-256-GCM; fresh nonce per call. + enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password)) + if err != nil { + u.logger.Error("encrypt pg password", "error", err) + u.renderError(w, r, http.StatusInternalServerError, "internal error") + return + } + + if err := u.db.WritePGCredentials(acct.ID, host, port, dbName, username, enc, nonce); err != nil { + u.logger.Error("write pg credentials", "error", err) + u.renderError(w, r, http.StatusInternalServerError, "failed to save credentials") + 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.EventPGCredUpdated, actorID, &acct.ID, "") + + // Security: set the credential owner to the actor on creation. + pgCred, err := u.db.ReadPGCredentials(acct.ID) + if err != nil { + u.logger.Warn("re-read pg credentials after create", "error", err) + } + if pgCred != nil && pgCred.OwnerID == nil && actorID != nil { + if err := u.db.SetPGCredentialOwner(pgCred.ID, *actorID); err != nil { + u.logger.Warn("set pg credential owner", "error", err) + } else { + pgCred.OwnerID = actorID + } + } + + // Redirect to the pgcreds list so the new entry appears in context. + http.Redirect(w, r, "/pgcreds", http.StatusSeeOther) +} + // handleIssueSystemToken issues a long-lived service token for a system account. func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") diff --git a/internal/ui/handlers_audit.go b/internal/ui/handlers_audit.go index ca8df55..f74a342 100644 --- a/internal/ui/handlers_audit.go +++ b/internal/ui/handlers_audit.go @@ -86,7 +86,7 @@ func (u *UIServer) handleAuditDetail(w http.ResponseWriter, r *http.Request) { } u.render(w, "audit_detail", AuditDetailData{ - PageData: PageData{CSRFToken: csrfToken}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, Event: event, }) } @@ -116,7 +116,7 @@ func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) ( } return AuditData{ - PageData: PageData{CSRFToken: csrfToken}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, Events: events, EventTypes: auditEventTypes, FilterType: filterType, diff --git a/internal/ui/handlers_dashboard.go b/internal/ui/handlers_dashboard.go index d694fc0..b35a2fd 100644 --- a/internal/ui/handlers_dashboard.go +++ b/internal/ui/handlers_dashboard.go @@ -37,7 +37,7 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) { } u.render(w, "dashboard", DashboardData{ - PageData: PageData{CSRFToken: csrfToken}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, TotalAccounts: total, ActiveAccounts: active, RecentEvents: events, diff --git a/internal/ui/handlers_policy.go b/internal/ui/handlers_policy.go index 9072986..951e2ff 100644 --- a/internal/ui/handlers_policy.go +++ b/internal/ui/handlers_policy.go @@ -60,7 +60,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) { } data := PoliciesData{ - PageData: PageData{CSRFToken: csrfToken}, + PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)}, Rules: views, AllActions: allActionStrings, } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c78b40b..e74a81b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -141,6 +141,22 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255 return false }, "not": func(b bool) bool { return !b }, + // derefInt64 safely dereferences a *int64, returning 0 for nil. + // Used in templates to compare owner IDs without triggering nil panics. + "derefInt64": func(p *int64) int64 { + if p == nil { + return 0 + } + return *p + }, + // isPGCredOwner returns true when actorID and cred are both non-nil + // and actorID matches cred.OwnerID. Safe to call with nil arguments. + "isPGCredOwner": func(actorID *int64, cred *model.PGCredential) bool { + if actorID == nil || cred == nil || cred.OwnerID == nil { + return false + } + return *actorID == *cred.OwnerID + }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "gt": func(a, b int) bool { return a > b }, @@ -190,6 +206,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255 "audit": "templates/audit.html", "audit_detail": "templates/audit_detail.html", "policies": "templates/policies.html", + "pgcreds": "templates/pgcreds.html", } tmpls := make(map[string]*template.Template, len(pageFiles)) for name, file := range pageFiles { @@ -264,6 +281,10 @@ func (u *UIServer) Register(mux *http.ServeMux) { uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken)) uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken)) uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds)) + uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess)) + uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess)) + uiMux.Handle("GET /pgcreds", adminGet(u.handlePGCredsList)) + uiMux.Handle("POST /pgcreds", admin(u.handleCreatePGCreds)) uiMux.Handle("GET /audit", adminGet(u.handleAuditPage)) uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows)) uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail)) @@ -478,6 +499,21 @@ func clientIP(r *http.Request) string { return addr } +// actorName resolves the username of the currently authenticated user from the +// request context. Returns an empty string if claims are absent or the account +// cannot be found; callers should treat an empty string as "not logged in". +func (u *UIServer) actorName(r *http.Request) string { + claims := claimsFromContext(r.Context()) + if claims == nil { + return "" + } + acct, err := u.db.GetAccountByUUID(claims.Subject) + if err != nil { + return "" + } + return acct.Username +} + // ---- Page data types ---- // PageData is embedded in all page-level view structs. @@ -485,6 +521,9 @@ type PageData struct { CSRFToken string Flash string Error string + // ActorName is the username of the currently logged-in user, populated by + // handlers so the base template can display it in the navigation bar. + ActorName string } // LoginData is the view model for the login page. @@ -514,7 +553,16 @@ type AccountsData struct { // AccountDetailData is the view model for the account detail page. type AccountDetailData struct { Account *model.Account - PGCred *model.PGCredential // nil if none stored or account is not a system account + // PGCred is nil if none stored or the account is not a system account. + PGCred *model.PGCredential + // PGCredGrants lists accounts that have been granted read access to PGCred. + // Only populated when the viewing actor is the credential owner. + PGCredGrants []*model.PGCredAccessGrant + // GrantableAccounts is the list of accounts the owner may grant access to. + GrantableAccounts []*model.Account + // ActorID is the DB id of the currently logged-in user; used in templates + // to decide whether to show the owner-only management controls. + ActorID *int64 PageData Roles []string AllRoles []string @@ -556,3 +604,21 @@ type PoliciesData struct { Rules []*PolicyRuleView AllActions []string } + +// PGCredsData is the view model for the "My PG Credentials" list page. +// It shows all pg_credentials sets accessible to the currently logged-in user: +// those they own and those they have been granted access to. +// UncredentialedAccounts is the list of system accounts that have no credentials +// yet, populated to drive the "New Credentials" create form on the same page. +// CredGrants maps credential ID to the list of access grants for that credential; +// only populated for credentials the actor owns. +// AllAccounts is used to populate the grant-access dropdown for owned credentials. +// ActorID is the DB id of the currently logged-in user. +type PGCredsData struct { + CredGrants map[int64][]*model.PGCredAccessGrant + ActorID *int64 + PageData + Creds []*model.PGCredential + UncredentialedAccounts []*model.Account + AllAccounts []*model.Account +} diff --git a/web/static/style.css b/web/static/style.css index 2f80df2..f2626f3 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -8,6 +8,7 @@ nav { background: #1a1a2e; color: #fff; padding: 0.5rem 1rem; } .nav-links { list-style: none; display: flex; gap: 1rem; margin: 0; padding: 0; } .nav-links a { color: #ccc; text-decoration: none; } .nav-links a:hover { color: #fff; } +.nav-actor { color: #aaa; font-size: 0.85rem; } .btn { display: inline-block; padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem; } .btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; } .btn-primary { background: #0d6efd; color: #fff; } diff --git a/web/templates/base.html b/web/templates/base.html index 4610871..83fdd08 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -15,6 +15,8 @@
  • Accounts
  • Audit
  • Policies
  • +
  • PG Creds
  • + {{if .ActorName}}
  • {{.ActorName}}
  • {{end}}
  • diff --git a/web/templates/fragments/pgcreds_form.html b/web/templates/fragments/pgcreds_form.html index 810bcbe..90c800a 100644 --- a/web/templates/fragments/pgcreds_form.html +++ b/web/templates/fragments/pgcreds_form.html @@ -11,25 +11,84 @@ {{else}}

    No credentials stored.

    {{end}} -
    -
    - - -
    -
    - - -
    - - -
    + + {{/* Any admin can add or update credentials; creator of the first set becomes owner */}} +
    + + {{if .PGCred}}Update credentials{{else}}Add new credentials{{end}} + +
    +
    + + +
    +
    + + +
    + + +
    +
    + + {{/* Access grants section — shown whenever credentials exist */}} + {{if .PGCred}} +
    +

    Access Grants

    + {{if .PGCredGrants}} + + + + + + {{if isPGCredOwner $.ActorID $.PGCred}}{{end}} + + + + {{range .PGCredGrants}} + + + + {{if isPGCredOwner $.ActorID $.PGCred}} + + {{end}} + + {{end}} + +
    UserGranted
    {{.GranteeName}}{{formatTime .GrantedAt}} + +
    + {{else}} +

    No access grants.

    + {{end}} + + {{/* Grant form — owner only */}} + {{if and (isPGCredOwner .ActorID .PGCred) .GrantableAccounts}} +
    + + +
    + {{end}} +
    + {{end}} {{end}} diff --git a/web/templates/pgcreds.html b/web/templates/pgcreds.html new file mode 100644 index 0000000..ef571d4 --- /dev/null +++ b/web/templates/pgcreds.html @@ -0,0 +1,134 @@ +{{define "pgcreds"}}{{template "base" .}}{{end}} +{{define "title"}}PG Credentials — MCIAS{{end}} +{{define "content"}} + + +{{if .Creds}} +
    +

    Your Credentials

    + {{range .Creds}} +
    +
    +
    Service Account
    {{.ServiceUsername}}
    +
    Host
    {{.PGHost}}:{{.PGPort}}
    +
    Database
    {{.PGDatabase}}
    +
    Username
    {{.PGUsername}}
    +
    Updated
    {{formatTime .UpdatedAt}}
    +
    + + {{/* Grant management — only for the credential owner */}} + {{$credID := .ID}} + {{$svcUUID := .ServiceAccountUUID}} + {{$grants := index $.CredGrants $credID}} + {{if isPGCredOwner $.ActorID .}} +
    +

    Access Grants

    + {{if $grants}} + + + + + + {{range $grants}} + + + + + + {{end}} + +
    UserGranted
    {{.GranteeName}}{{formatTime .GrantedAt}} + +
    + {{else}} +

    No access grants.

    + {{end}} + + {{/* Plain POST grant form with redirect back to /pgcreds */}} +
    + + + + +
    +
    + {{end}} + +
    + View Account +
    +
    + {{end}} +
    +{{else}} +
    +

    No Postgres credentials are accessible to your account.

    +
    +{{end}} + +
    +
    +

    New Credentials

    + +
    + +
    +{{end}}