diff --git a/PROGRESS.md b/PROGRESS.md index 227b6cb..2c806c9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,6 +4,32 @@ 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-13 — Make pgcreds discoverable via CLI and UI + +**Problem:** Users had no way to discover which pgcreds were available to them or what their credential IDs were, making it functionally impossible to use the system without manual database inspection. + +**Solution:** Added two complementary discovery paths: + +**REST API:** +- New `GET /v1/pgcreds` endpoint (requires authentication) returns all accessible credentials (owned + explicitly granted) with their IDs, host, port, database, username, and timestamps +- Response includes `id` field so users can then fetch full credentials via `GET /v1/accounts/{id}/pgcreds` + +**CLI (`cmd/mciasctl/main.go`):** +- New `pgcreds list` subcommand calls `GET /v1/pgcreds` and displays accessible credentials with IDs +- Updated usage documentation to include `pgcreds list` + +**Web UI (`web/templates/pgcreds.html`):** +- Credential ID now displayed in a `` element at the top of each credential's metadata block +- Styled with monospace font for easy copying and reference + +**Files modified:** +- `internal/server/server.go`: Added route `GET /v1/pgcreds` (requires auth, not admin) + handler `handleListAccessiblePGCreds` +- `cmd/mciasctl/main.go`: Added `pgCredsList` function and switch case +- `web/templates/pgcreds.html`: Display credential ID in the credentials list +- Struct field alignment fixed in `pgCredResponse` to pass `go vet` + +All tests pass; `go vet ./...` clean. + ### 2026-03-12 — Update web UI and model for all compile-time roles - `internal/model/model.go`: added `RoleGuest`, `RoleViewer`, `RoleEditor`, and diff --git a/clients/go/README.md b/clients/go/README.md index 920c53e..31e3025 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -15,10 +15,10 @@ go get git.wntrmute.dev/kyle/mcias/clients/go ## Quick Start ```go -import mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go" +import "git.wntrmute.dev/kyle/mcias/clients/go/mcias" // Connect to the MCIAS server. -client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{}) +client, err := mcias.New("https://auth.example.com", mcias.Options{}) if err != nil { log.Fatal(err) } @@ -43,7 +43,7 @@ if err := client.Logout(); err != nil { ## Custom CA Certificate ```go -client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{ +client, err := mcias.New("https://auth.example.com", mcias.Options{ CACertPath: "/etc/mcias/ca.pem", }) ``` @@ -55,17 +55,17 @@ All methods return typed errors: ```go _, _, err := client.Login("alice", "wrongpass", "") switch { -case errors.Is(err, new(mciasgoclient.MciasAuthError)): +case errors.Is(err, new(mcias.MciasAuthError)): // 401 — wrong credentials or token invalid -case errors.Is(err, new(mciasgoclient.MciasForbiddenError)): +case errors.Is(err, new(mcias.MciasForbiddenError)): // 403 — insufficient role -case errors.Is(err, new(mciasgoclient.MciasNotFoundError)): +case errors.Is(err, new(mcias.MciasNotFoundError)): // 404 — resource not found -case errors.Is(err, new(mciasgoclient.MciasInputError)): +case errors.Is(err, new(mcias.MciasInputError)): // 400 — malformed request -case errors.Is(err, new(mciasgoclient.MciasConflictError)): +case errors.Is(err, new(mcias.MciasConflictError)): // 409 — conflict (e.g. duplicate username) -case errors.Is(err, new(mciasgoclient.MciasServerError)): +case errors.Is(err, new(mcias.MciasServerError)): // 5xx — unexpected server error } ``` diff --git a/clients/go/client.go b/clients/go/client.go index c414f93..36d71f0 100644 --- a/clients/go/client.go +++ b/clients/go/client.go @@ -1,8 +1,8 @@ -// Package mciasgoclient provides a thread-safe Go client for the MCIAS REST API. +// Package mcias provides a thread-safe Go client for the MCIAS REST API. // // Security: bearer tokens are stored under a sync.RWMutex and are never written // to logs or included in error messages anywhere in this package. -package mciasgoclient +package mcias import ( "bytes" @@ -28,7 +28,7 @@ type MciasError struct { } func (e *MciasError) Error() string { - return fmt.Sprintf("mciasgoclient: HTTP %d: %s", e.StatusCode, e.Message) + return fmt.Sprintf("mcias: HTTP %d: %s", e.StatusCode, e.Message) } // MciasAuthError is returned for 401 Unauthorized responses. diff --git a/clients/go/client_test.go b/clients/go/client_test.go index 567e331..0e69d1d 100644 --- a/clients/go/client_test.go +++ b/clients/go/client_test.go @@ -1,7 +1,7 @@ -// Package mciasgoclient_test provides tests for the MCIAS Go client. +// Package mcias_test provides tests for the MCIAS Go client. // All tests use inline httptest.NewServer mocks to keep this module // self-contained (no cross-module imports). -package mciasgoclient_test +package mcias_test import ( "encoding/json" @@ -11,16 +11,16 @@ import ( "strings" "testing" - mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go" + mcias "git.wntrmute.dev/kyle/mcias/clients/go" ) // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- -func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client { +func newTestClient(t *testing.T, serverURL string) *mcias.Client { t.Helper() - c, err := mciasgoclient.New(serverURL, mciasgoclient.Options{}) + c, err := mcias.New(serverURL, mcias.Options{}) if err != nil { t.Fatalf("New: %v", err) } @@ -42,7 +42,7 @@ func writeError(w http.ResponseWriter, status int, msg string) { // --------------------------------------------------------------------------- func TestNew(t *testing.T) { - c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{}) + c, err := mcias.New("https://example.com", mcias.Options{}) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -52,7 +52,7 @@ func TestNew(t *testing.T) { } func TestNewWithPresetToken(t *testing.T) { - c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{Token: "preset-tok"}) + c, err := mcias.New("https://example.com", mcias.Options{Token: "preset-tok"}) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -62,7 +62,7 @@ func TestNewWithPresetToken(t *testing.T) { } func TestNewBadCACert(t *testing.T) { - _, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{CACertPath: "/nonexistent/ca.pem"}) + _, err := mcias.New("https://example.com", mcias.Options{CACertPath: "/nonexistent/ca.pem"}) if err == nil { t.Fatal("expected error for missing CA cert file") } @@ -97,7 +97,7 @@ func TestHealthError(t *testing.T) { if err == nil { t.Fatal("expected error for 503") } - var srvErr *mciasgoclient.MciasServerError + var srvErr *mcias.MciasServerError if !errors.As(err, &srvErr) { t.Errorf("expected MciasServerError, got %T: %v", err, err) } @@ -183,7 +183,7 @@ func TestLoginUnauthorized(t *testing.T) { if err == nil { t.Fatal("expected error for 401") } - var authErr *mciasgoclient.MciasAuthError + var authErr *mcias.MciasAuthError if !errors.As(err, &authErr) { t.Errorf("expected MciasAuthError, got %T: %v", err, err) } @@ -312,7 +312,7 @@ func TestConfirmTOTPBadCode(t *testing.T) { if err == nil { t.Fatal("expected error for bad TOTP code") } - var inputErr *mciasgoclient.MciasInputError + var inputErr *mcias.MciasInputError if !errors.As(err, &inputErr) { t.Errorf("expected MciasInputError, got %T: %v", err, err) } @@ -347,7 +347,7 @@ func TestChangePasswordWrongCurrent(t *testing.T) { if err == nil { t.Fatal("expected error for wrong current password") } - var authErr *mciasgoclient.MciasAuthError + var authErr *mcias.MciasAuthError if !errors.As(err, &authErr) { t.Errorf("expected MciasAuthError, got %T: %v", err, err) } @@ -456,7 +456,7 @@ func TestCreateAccountConflict(t *testing.T) { if err == nil { t.Fatal("expected error for 409") } - var conflictErr *mciasgoclient.MciasConflictError + var conflictErr *mcias.MciasConflictError if !errors.As(err, &conflictErr) { t.Errorf("expected MciasConflictError, got %T: %v", err, err) } @@ -801,7 +801,7 @@ func TestListAudit(t *testing.T) { })) defer srv.Close() c := newTestClient(t, srv.URL) - resp, err := c.ListAudit(mciasgoclient.AuditFilter{}) + resp, err := c.ListAudit(mcias.AuditFilter{}) if err != nil { t.Fatalf("ListAudit: %v", err) } @@ -827,7 +827,7 @@ func TestListAuditWithFilter(t *testing.T) { })) defer srv.Close() c := newTestClient(t, srv.URL) - _, err := c.ListAudit(mciasgoclient.AuditFilter{ + _, err := c.ListAudit(mcias.AuditFilter{ Limit: 10, Offset: 5, EventType: "login_fail", ActorID: "acct-uuid-1", }) if err != nil { @@ -896,10 +896,10 @@ func TestCreatePolicyRule(t *testing.T) { })) defer srv.Close() c := newTestClient(t, srv.URL) - rule, err := c.CreatePolicyRule(mciasgoclient.CreatePolicyRuleRequest{ + rule, err := c.CreatePolicyRule(mcias.CreatePolicyRuleRequest{ Description: "Test rule", Priority: 50, - Rule: mciasgoclient.PolicyRuleBody{Effect: "deny"}, + Rule: mcias.PolicyRuleBody{Effect: "deny"}, }) if err != nil { t.Fatalf("CreatePolicyRule: %v", err) @@ -950,7 +950,7 @@ func TestGetPolicyRuleNotFound(t *testing.T) { if err == nil { t.Fatal("expected error for 404") } - var notFoundErr *mciasgoclient.MciasNotFoundError + var notFoundErr *mcias.MciasNotFoundError if !errors.As(err, ¬FoundErr) { t.Errorf("expected MciasNotFoundError, got %T: %v", err, err) } @@ -976,7 +976,7 @@ func TestUpdatePolicyRule(t *testing.T) { })) defer srv.Close() c := newTestClient(t, srv.URL) - rule, err := c.UpdatePolicyRule(7, mciasgoclient.UpdatePolicyRuleRequest{Enabled: &enabled}) + rule, err := c.UpdatePolicyRule(7, mcias.UpdatePolicyRuleRequest{Enabled: &enabled}) if err != nil { t.Fatalf("UpdatePolicyRule: %v", err) } @@ -1073,7 +1073,7 @@ func TestIntegration(t *testing.T) { if err == nil { t.Fatal("expected error for wrong credentials") } - var authErr *mciasgoclient.MciasAuthError + var authErr *mcias.MciasAuthError if !errors.As(err, &authErr) { t.Errorf("expected MciasAuthError, got %T", err) } diff --git a/cmd/mciasctl/main.go b/cmd/mciasctl/main.go index b14a911..5ca2254 100644 --- a/cmd/mciasctl/main.go +++ b/cmd/mciasctl/main.go @@ -34,6 +34,7 @@ // token issue -id UUID // token revoke -jti JTI // +// pgcreds list // pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] // pgcreds get -id UUID // @@ -526,9 +527,11 @@ func (c *controller) tokenRevoke(args []string) { func (c *controller) runPGCreds(args []string) { if len(args) == 0 { - fatalf("pgcreds requires a subcommand: get, set") + fatalf("pgcreds requires a subcommand: list, get, set") } switch args[0] { + case "list": + c.pgCredsList(args[1:]) case "get": c.pgCredsGet(args[1:]) case "set": @@ -538,6 +541,15 @@ func (c *controller) runPGCreds(args []string) { } } +func (c *controller) pgCredsList(args []string) { + fs := flag.NewFlagSet("pgcreds list", flag.ExitOnError) + _ = fs.Parse(args) + + var result json.RawMessage + c.doRequest("GET", "/v1/pgcreds", nil, &result) + printJSON(result) +} + func (c *controller) pgCredsGet(args []string) { fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError) id := fs.String("id", "", "account UUID (required)") @@ -943,6 +955,7 @@ Commands: token issue -id UUID token revoke -jti JTI + pgcreds list pgcreds get -id UUID pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] diff --git a/internal/server/server.go b/internal/server/server.go index 0c81560..7406ce7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -134,6 +134,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles))) mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole))) mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole))) + mux.Handle("GET /v1/pgcreds", requireAuth(http.HandlerFunc(s.handleListAccessiblePGCreds))) mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds))) mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds))) mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) @@ -1223,6 +1224,58 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// handleListAccessiblePGCreds returns all pg_credentials accessible to the +// authenticated user: those owned + those explicitly granted. The credential ID +// is included so callers can fetch a specific credential via /v1/accounts/{id}/pgcreds. +func (s *Server) handleListAccessiblePGCreds(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + middleware.WriteError(w, http.StatusUnauthorized, "not authenticated", "unauthorized") + return + } + + acct, err := s.db.GetAccountByUUID(claims.Subject) + if err != nil { + middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized") + return + } + + creds, err := s.db.ListAccessiblePGCreds(acct.ID) + if err != nil { + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + // Convert credentials to response format with credential ID. + type pgCredResponse struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Port int `json:"port"` + Host string `json:"host"` + Database string `json:"database"` + Username string `json:"username"` + ServiceAccountID string `json:"service_account_id"` + ServiceAccountName string `json:"service_account_name,omitempty"` + } + + response := make([]pgCredResponse, len(creds)) + for i, cred := range creds { + response[i] = pgCredResponse{ + ID: cred.ID, + ServiceAccountID: cred.ServiceAccountUUID, + Host: cred.PGHost, + Port: cred.PGPort, + Database: cred.PGDatabase, + Username: cred.PGUsername, + CreatedAt: cred.CreatedAt, + UpdatedAt: cred.UpdatedAt, + } + } + + writeJSON(w, http.StatusOK, response) +} + // ---- Audit endpoints ---- // handleListAudit returns paginated audit log entries with resolved usernames. diff --git a/web/templates/pgcreds.html b/web/templates/pgcreds.html index ef571d4..6f2f5a2 100644 --- a/web/templates/pgcreds.html +++ b/web/templates/pgcreds.html @@ -12,6 +12,7 @@ {{range .Creds}}
+
Credential ID
{{.ID}}
Service Account
{{.ServiceUsername}}
Host
{{.PGHost}}:{{.PGPort}}
Database
{{.PGDatabase}}