UI: pgcreds create button; show logged-in user

* web/templates/pgcreds.html: New Credentials card is now always
  rendered; Add Credentials toggle button reveals the create form
  (hidden by default). Shows a message when all system accounts
  already have credentials. Previously the card was hidden when
  UncredentialedAccounts was empty.
* internal/ui/ui.go: added ActorName string field to PageData;
  added actorName(r) helper resolving username from JWT claims
  via DB lookup, returns empty string if unauthenticated.
* internal/ui/handlers_*.go: all full-page PageData constructors
  now pass ActorName: u.actorName(r).
* web/templates/base.html: nav bar renders actor username as a
  muted label before the Logout button when logged in.
* web/static/style.css: added .nav-actor rule (muted grey, 0.85rem).
This commit is contained in:
2026-03-12 11:38:57 -07:00
parent bbf9f6fe3f
commit b2f2f04646
19 changed files with 1152 additions and 49 deletions

0
.junie/memory/errors.md Normal file
View File

View File

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
1.0

0
.junie/memory/tasks.md Normal file
View File

View File

@@ -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. 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" `<details>` 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 ### 2026-03-11 — Postgres Credentials UI + Policy/Tags UI completion
**internal/ui/** **internal/ui/**

View File

@@ -450,16 +450,17 @@ func (db *DB) WritePGCredentials(accountID int64, host string, port int, dbName,
func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) { func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
var cred model.PGCredential var cred model.PGCredential
var createdAtStr, updatedAtStr string var createdAtStr, updatedAtStr string
var ownerID sql.NullInt64
err := db.sql.QueryRow(` err := db.sql.QueryRow(`
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username, 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 = ? FROM pg_credentials WHERE account_id = ?
`, accountID).Scan( `, accountID).Scan(
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort, &cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
&cred.PGDatabase, &cred.PGUsername, &cred.PGDatabase, &cred.PGUsername,
&cred.PGPasswordEnc, &cred.PGPasswordNonce, &cred.PGPasswordEnc, &cred.PGPasswordNonce,
&createdAtStr, &updatedAtStr, &createdAtStr, &updatedAtStr, &ownerID,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -476,6 +477,10 @@ func (db *DB) ReadPGCredentials(accountID int64) (*model.PGCredential, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if ownerID.Valid {
v := ownerID.Int64
cred.OwnerID = &v
}
return &cred, nil return &cred, nil
} }

View File

@@ -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')), 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')) 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);
`, `,
}, },
} }

View File

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

View File

@@ -87,18 +87,26 @@ type SystemToken struct {
// PGCredential holds Postgres connection details for a system account. // PGCredential holds Postgres connection details for a system account.
// The password is encrypted at rest; PGPassword is only populated after // The password is encrypted at rest; PGPassword is only populated after
// decryption and must never be logged or included in API responses. // 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 { type PGCredential struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
PGHost string `json:"host"` OwnerID *int64 `json:"-"`
PGDatabase string `json:"database"` ServiceAccountUUID string `json:"service_account_uuid,omitempty"`
PGUsername string `json:"username"` PGUsername string `json:"username"`
PGPassword string `json:"-"` PGPassword string `json:"-"`
PGPasswordEnc []byte `json:"-"` ServiceUsername string `json:"service_username,omitempty"`
PGPasswordNonce []byte `json:"-"` PGDatabase string `json:"database"`
ID int64 `json:"-"` PGHost string `json:"host"`
AccountID int64 `json:"-"` PGPasswordEnc []byte `json:"-"`
PGPort int `json:"port"` PGPasswordNonce []byte `json:"-"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
PGPort int `json:"port"`
} }
// AuditEvent represents a single entry in the append-only audit log. // AuditEvent represents a single entry in the append-only audit log.
@@ -141,6 +149,26 @@ const (
EventPolicyDeny = "policy_deny" 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. // PolicyRuleRecord is the database representation of a policy rule.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields). // RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns. // The ID, Priority, and Description are stored as dedicated columns.

View File

@@ -32,7 +32,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) {
} }
u.render(w, "accounts", AccountsData{ u.render(w, "accounts", AccountsData{
PageData: PageData{CSRFToken: csrfToken}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Accounts: accounts, Accounts: accounts,
}) })
} }
@@ -132,15 +132,41 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
tokens = nil 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 // Load PG credentials for system accounts only; leave nil for human accounts
// and when no credentials have been stored yet. // and when no credentials have been stored yet.
var pgCred *model.PGCredential var pgCred *model.PGCredential
var pgCredGrants []*model.PGCredAccessGrant
var grantableAccounts []*model.Account
if acct.AccountType == model.AccountTypeSystem { if acct.AccountType == model.AccountTypeSystem {
pgCred, err = u.db.ReadPGCredentials(acct.ID) pgCred, err = u.db.ReadPGCredentials(acct.ID)
if err != nil && !errors.Is(err, db.ErrNotFound) { if err != nil && !errors.Is(err, db.ErrNotFound) {
u.logger.Warn("read pg credentials", "error", err) u.logger.Warn("read pg credentials", "error", err)
} }
// ErrNotFound is expected when no credentials have been stored yet. // 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) 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{ u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Account: acct, Account: acct,
Roles: roles, Roles: roles,
AllRoles: knownRoles, AllRoles: knownRoles,
Tokens: tokens, Tokens: tokens,
PGCred: pgCred, PGCred: pgCred,
Tags: tags, PGCredGrants: pgCredGrants,
GrantableAccounts: grantableAccounts,
ActorID: actorID,
Tags: tags,
}) })
} }
@@ -456,18 +485,417 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
pgCred = nil 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) csrfToken, err := u.setCSRFCookies(w)
if err != nil { if err != nil {
csrfToken = "" csrfToken = ""
} }
u.render(w, "pgcreds_form", AccountDetailData{ u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken}, PageData: PageData{CSRFToken: csrfToken},
Account: acct, Account: acct,
PGCred: pgCred, 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. // handleIssueSystemToken issues a long-lived service token for a system account.
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")

View File

@@ -86,7 +86,7 @@ func (u *UIServer) handleAuditDetail(w http.ResponseWriter, r *http.Request) {
} }
u.render(w, "audit_detail", AuditDetailData{ u.render(w, "audit_detail", AuditDetailData{
PageData: PageData{CSRFToken: csrfToken}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Event: event, Event: event,
}) })
} }
@@ -116,7 +116,7 @@ func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) (
} }
return AuditData{ return AuditData{
PageData: PageData{CSRFToken: csrfToken}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Events: events, Events: events,
EventTypes: auditEventTypes, EventTypes: auditEventTypes,
FilterType: filterType, FilterType: filterType,

View File

@@ -37,7 +37,7 @@ func (u *UIServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
u.render(w, "dashboard", DashboardData{ u.render(w, "dashboard", DashboardData{
PageData: PageData{CSRFToken: csrfToken}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
TotalAccounts: total, TotalAccounts: total,
ActiveAccounts: active, ActiveAccounts: active,
RecentEvents: events, RecentEvents: events,

View File

@@ -60,7 +60,7 @@ func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
} }
data := PoliciesData{ data := PoliciesData{
PageData: PageData{CSRFToken: csrfToken}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r)},
Rules: views, Rules: views,
AllActions: allActionStrings, AllActions: allActionStrings,
} }

View File

@@ -141,6 +141,22 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
return false return false
}, },
"not": func(b bool) bool { return !b }, "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 }, "add": func(a, b int) int { return a + b },
"sub": 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 }, "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": "templates/audit.html",
"audit_detail": "templates/audit_detail.html", "audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html", "policies": "templates/policies.html",
"pgcreds": "templates/pgcreds.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 {
@@ -264,6 +281,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken)) uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken)) uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds)) 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", 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))
@@ -478,6 +499,21 @@ func clientIP(r *http.Request) string {
return addr 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 ---- // ---- Page data types ----
// PageData is embedded in all page-level view structs. // PageData is embedded in all page-level view structs.
@@ -485,6 +521,9 @@ type PageData struct {
CSRFToken string CSRFToken string
Flash string Flash string
Error 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. // 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. // AccountDetailData is the view model for the account detail page.
type AccountDetailData struct { type AccountDetailData struct {
Account *model.Account 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 PageData
Roles []string Roles []string
AllRoles []string AllRoles []string
@@ -556,3 +604,21 @@ type PoliciesData struct {
Rules []*PolicyRuleView Rules []*PolicyRuleView
AllActions []string 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
}

View File

@@ -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 { list-style: none; display: flex; gap: 1rem; margin: 0; padding: 0; }
.nav-links a { color: #ccc; text-decoration: none; } .nav-links a { color: #ccc; text-decoration: none; }
.nav-links a:hover { color: #fff; } .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 { 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-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
.btn-primary { background: #0d6efd; color: #fff; } .btn-primary { background: #0d6efd; color: #fff; }

View File

@@ -15,6 +15,8 @@
<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><a href="/policies">Policies</a></li>
<li><a href="/pgcreds">PG Creds</a></li>
{{if .ActorName}}<li><span class="nav-actor">{{.ActorName}}</span></li>{{end}}
<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>

View File

@@ -11,25 +11,84 @@
{{else}} {{else}}
<p class="text-muted text-small" style="margin-bottom:1rem">No credentials stored.</p> <p class="text-muted text-small" style="margin-bottom:1rem">No credentials stored.</p>
{{end}} {{end}}
<form hx-put="/accounts/{{.Account.UUID}}/pgcreds"
hx-target="#pgcreds-section" hx-swap="outerHTML"> {{/* Any admin can add or update credentials; creator of the first set becomes owner */}}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem"> <details style="margin-bottom:1rem">
<input class="form-control" type="text" name="host" placeholder="Host" required <summary class="text-small" style="cursor:pointer;color:var(--color-text-muted)">
value="{{if .PGCred}}{{.PGCred.PGHost}}{{end}}"> {{if .PGCred}}Update credentials{{else}}Add new credentials{{end}}
<input class="form-control" type="number" name="port" placeholder="Port (5432)" </summary>
min="1" max="65535" <form hx-put="/accounts/{{.Account.UUID}}/pgcreds"
value="{{if .PGCred}}{{.PGCred.PGPort}}{{end}}"> hx-target="#pgcreds-section" hx-swap="outerHTML"
</div> style="margin-top:.75rem">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<input class="form-control" type="text" name="database" placeholder="Database" required <input class="form-control" type="text" name="host" placeholder="Host" required
value="{{if .PGCred}}{{.PGCred.PGDatabase}}{{end}}"> {{if .PGCred}}value="{{.PGCred.PGHost}}"{{end}}>
<input class="form-control" type="text" name="username" placeholder="Username" required <input class="form-control" type="number" name="port" placeholder="Port (5432)"
value="{{if .PGCred}}{{.PGCred.PGUsername}}{{end}}"> min="1" max="65535"
</div> {{if .PGCred}}value="{{.PGCred.PGPort}}"{{end}}>
<input class="form-control" type="password" name="password" </div>
placeholder="Password (required to update)" required <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
style="margin-bottom:.5rem"> <input class="form-control" type="text" name="database" placeholder="Database" required
<button class="btn btn-sm btn-secondary" type="submit">Save Credentials</button> {{if .PGCred}}value="{{.PGCred.PGDatabase}}"{{end}}>
</form> <input class="form-control" type="text" name="username" placeholder="Username" required
{{if .PGCred}}value="{{.PGCred.PGUsername}}"{{end}}>
</div>
<input class="form-control" type="password" name="password"
placeholder="Password (required)" required
style="margin-bottom:.5rem">
<button class="btn btn-sm btn-secondary" type="submit">Save Credentials</button>
</form>
</details>
{{/* Access grants section — shown whenever credentials exist */}}
{{if .PGCred}}
<div style="margin-top:1.25rem">
<h3 style="font-size:.9rem;font-weight:600;margin-bottom:.5rem">Access Grants</h3>
{{if .PGCredGrants}}
<table class="table table-sm" style="font-size:.85rem">
<thead>
<tr>
<th>User</th>
<th>Granted</th>
{{if isPGCredOwner $.ActorID $.PGCred}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range .PGCredGrants}}
<tr>
<td>{{.GranteeName}}</td>
<td class="text-small text-muted">{{formatTime .GrantedAt}}</td>
{{if isPGCredOwner $.ActorID $.PGCred}}
<td>
<button class="btn btn-sm btn-danger"
hx-delete="/accounts/{{$.Account.UUID}}/pgcreds/access/{{.GranteeUUID}}"
hx-target="#pgcreds-section" hx-swap="outerHTML"
hx-confirm="Revoke access for {{.GranteeName}}?">Revoke</button>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted text-small">No access grants.</p>
{{end}}
{{/* Grant form — owner only */}}
{{if and (isPGCredOwner .ActorID .PGCred) .GrantableAccounts}}
<form hx-post="/accounts/{{.Account.UUID}}/pgcreds/access"
hx-target="#pgcreds-section" hx-swap="outerHTML"
style="margin-top:.75rem;display:flex;gap:.5rem;align-items:center">
<select class="form-control" name="grantee_uuid" required style="flex:1">
<option value="">— select account to grant —</option>
{{range .GrantableAccounts}}
<option value="{{.UUID}}">{{.Username}} ({{.AccountType}})</option>
{{end}}
</select>
<button class="btn btn-sm btn-secondary" type="submit">Grant Access</button>
</form>
{{end}}
</div>
{{end}}
</div> </div>
{{end}} {{end}}

134
web/templates/pgcreds.html Normal file
View File

@@ -0,0 +1,134 @@
{{define "pgcreds"}}{{template "base" .}}{{end}}
{{define "title"}}PG Credentials — MCIAS{{end}}
{{define "content"}}
<div class="page-header">
<h1>Postgres Credentials</h1>
<p class="text-muted text-small">Credentials you own or have been granted access to.</p>
</div>
{{if .Creds}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Your Credentials</h2>
{{range .Creds}}
<div style="border:1px solid var(--color-border);border-radius:6px;padding:1rem;margin-bottom:1rem">
<dl style="display:grid;grid-template-columns:140px 1fr;gap:.35rem .75rem;font-size:.9rem;margin-bottom:.75rem">
<dt class="text-muted">Service Account</dt><dd>{{.ServiceUsername}}</dd>
<dt class="text-muted">Host</dt><dd>{{.PGHost}}:{{.PGPort}}</dd>
<dt class="text-muted">Database</dt><dd>{{.PGDatabase}}</dd>
<dt class="text-muted">Username</dt><dd>{{.PGUsername}}</dd>
<dt class="text-muted">Updated</dt><dd class="text-small text-muted">{{formatTime .UpdatedAt}}</dd>
</dl>
{{/* Grant management — only for the credential owner */}}
{{$credID := .ID}}
{{$svcUUID := .ServiceAccountUUID}}
{{$grants := index $.CredGrants $credID}}
{{if isPGCredOwner $.ActorID .}}
<div style="margin-top:.75rem">
<h3 style="font-size:.85rem;font-weight:600;margin-bottom:.5rem">Access Grants</h3>
{{if $grants}}
<table class="table table-sm" style="font-size:.85rem;margin-bottom:.75rem">
<thead>
<tr><th>User</th><th>Granted</th><th></th></tr>
</thead>
<tbody>
{{range $grants}}
<tr>
<td>{{.GranteeName}}</td>
<td class="text-small text-muted">{{formatTime .GrantedAt}}</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="/accounts/{{$svcUUID}}/pgcreds/access/{{.GranteeUUID}}?_next=/pgcreds"
hx-confirm="Revoke access for {{.GranteeName}}?">Revoke</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted text-small" style="margin-bottom:.5rem">No access grants.</p>
{{end}}
{{/* Plain POST grant form with redirect back to /pgcreds */}}
<form method="POST" action="/accounts/{{.ServiceAccountUUID}}/pgcreds/access"
style="display:flex;gap:.5rem;align-items:center">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="_next" value="/pgcreds">
<select class="form-control" name="grantee_uuid" required style="flex:1">
<option value="">— select account to grant —</option>
{{range $.AllAccounts}}
<option value="{{.UUID}}">{{.Username}} ({{.AccountType}})</option>
{{end}}
</select>
<button class="btn btn-sm btn-secondary" type="submit">Grant Access</button>
</form>
</div>
{{end}}
<div style="margin-top:.75rem">
<a class="btn btn-sm btn-secondary" href="/accounts/{{.ServiceAccountUUID}}">View Account</a>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="card">
<p class="text-muted">No Postgres credentials are accessible to your account.</p>
</div>
{{end}}
<div class="card" style="margin-top:1.5rem">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem">
<h2 style="font-size:1rem;font-weight:600;margin:0">New Credentials</h2>
<button class="btn btn-sm btn-secondary"
onclick="var f=document.getElementById('pgcreds-create-form');f.hidden=!f.hidden;this.textContent=f.hidden?'Add Credentials':'Cancel'">Add Credentials</button>
</div>
<div id="pgcreds-create-form" hidden>
<p class="text-muted text-small" style="margin-bottom:1rem;margin-top:.5rem">
Create a credential set for a system account. You will become the owner and
may later grant other accounts read access.
</p>
{{if .UncredentialedAccounts}}
<form method="POST" action="/pgcreds">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div style="margin-bottom:.75rem">
<label class="form-label" for="pgcreds-account">Service Account</label>
<select id="pgcreds-account" class="form-control" name="account_uuid" required>
<option value="">— select system account —</option>
{{range .UncredentialedAccounts}}
<option value="{{.UUID}}">{{.Username}}</option>
{{end}}
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="form-label" for="pgcreds-host">Host</label>
<input id="pgcreds-host" class="form-control" type="text" name="host" placeholder="db.example.com" required>
</div>
<div>
<label class="form-label" for="pgcreds-port">Port</label>
<input id="pgcreds-port" class="form-control" type="number" name="port" placeholder="5432" min="1" max="65535">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="form-label" for="pgcreds-db">Database</label>
<input id="pgcreds-db" class="form-control" type="text" name="database" placeholder="myapp" required>
</div>
<div>
<label class="form-label" for="pgcreds-user">Username</label>
<input id="pgcreds-user" class="form-control" type="text" name="username" placeholder="svc_user" required>
</div>
</div>
<div style="margin-bottom:.75rem">
<label class="form-label" for="pgcreds-password">Password</label>
<input id="pgcreds-password" class="form-control" type="password" name="password" required>
</div>
<button class="btn btn-secondary" type="submit">Create Credentials</button>
</form>
{{else}}
<p class="text-muted text-small">All system accounts already have credentials. Create a new system account first.</p>
{{end}}
</div>
</div>
{{end}}