Rename Go client package from mciasgoclient to mcias

- Update package declaration in client.go
- Update error message strings to reference new package name
- Update test package and imports to use new name
- Update README.md documentation and examples with new package name
- All tests pass
This commit is contained in:
2026-03-14 19:01:07 -07:00
parent 7e5fc9f111
commit 8f09e0e81a
7 changed files with 126 additions and 33 deletions

View File

@@ -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 `<code>` 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

View File

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

View File

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

View File

@@ -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, &notFoundErr) {
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)
}

View File

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

View File

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

View File

@@ -12,6 +12,7 @@
{{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">Credential ID</dt><dd><code style="font-size:.8rem;color:var(--color-fg-muted)">{{.ID}}</code></dd>
<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>