Implement Phase 9: client libraries (Go, Rust, Lisp, Python)

- clients/README.md: canonical API surface and error type reference
- clients/testdata/: shared JSON response fixtures
- clients/go/: mciasgoclient package; net/http + TLS 1.2+; sync.RWMutex
  token state; DisallowUnknownFields on all decoders; 25 tests pass
- clients/rust/: async mcias-client crate; reqwest+rustls (no OpenSSL);
  thiserror MciasError enum; Arc<RwLock> token state; 22+1 tests pass;
  cargo clippy -D warnings clean
- clients/lisp/: ASDF mcias-client; dexador HTTP, yason JSON; mcias-error
  condition hierarchy; Hunchentoot mock-dispatcher; 37 fiveam checks pass
  on SBCL 2.6.1; yason boolean normalisation in validate-token
- clients/python/: mcias_client package (Python 3.11+); httpx sync;
  py.typed; dataclasses; 32 pytest tests; mypy --strict + ruff clean
- test/mock/mockserver.go: in-memory mock server for Go client tests
- ARCHITECTURE.md §19: updated per-language notes to match implementation
- PROGRESS.md: Phase 9 marked complete
- .gitignore: exclude clients/rust/target/, python .venv, .pytest_cache,
  .fasl files
Security: token never logged or exposed in error messages in any library;
TLS enforced in all four languages; token stored under lock/mutex/RwLock
This commit is contained in:
2026-03-11 16:38:32 -07:00
parent f34e9a69a0
commit 0c441f5c4f
1974 changed files with 10151 additions and 33 deletions

378
clients/go/client.go Normal file
View File

@@ -0,0 +1,378 @@
// Package mciasgoclient 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
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
)
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
// MciasError is the base error type for all MCIAS client errors.
type MciasError struct {
StatusCode int
Message string
}
func (e *MciasError) Error() string {
return fmt.Sprintf("mciasgoclient: HTTP %d: %s", e.StatusCode, e.Message)
}
// MciasAuthError is returned for 401 Unauthorized responses.
type MciasAuthError struct{ MciasError }
// MciasForbiddenError is returned for 403 Forbidden responses.
type MciasForbiddenError struct{ MciasError }
// MciasNotFoundError is returned for 404 Not Found responses.
type MciasNotFoundError struct{ MciasError }
// MciasInputError is returned for 400 Bad Request responses.
type MciasInputError struct{ MciasError }
// MciasConflictError is returned for 409 Conflict responses.
type MciasConflictError struct{ MciasError }
// MciasServerError is returned for 5xx responses.
type MciasServerError struct{ MciasError }
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
// Account represents a user or service account.
type Account struct {
ID string `json:"id"`
Username string `json:"username"`
AccountType string `json:"account_type"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
TOTPEnabled bool `json:"totp_enabled"`
}
// PublicKey represents the server's Ed25519 public key in JWK format.
type PublicKey struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Use string `json:"use,omitempty"`
Alg string `json:"alg,omitempty"`
}
// TokenClaims is returned by ValidateToken.
type TokenClaims struct {
Valid bool `json:"valid"`
Sub string `json:"sub,omitempty"`
Roles []string `json:"roles,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// PGCreds holds Postgres connection credentials.
type PGCreds struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
}
// ---------------------------------------------------------------------------
// Options and Client struct
// ---------------------------------------------------------------------------
// Options configures the MCIAS client.
type Options struct {
// CACertPath is an optional path to a PEM-encoded CA certificate for TLS
// verification of self-signed or private-CA certificates.
CACertPath string
// Token is an optional pre-existing bearer token.
Token string
}
// Client is a thread-safe MCIAS REST API client.
// Security: the bearer token is guarded by a sync.RWMutex; it is never
// written to logs or included in error messages in this library.
type Client struct {
baseURL string
http *http.Client
mu sync.RWMutex
token string
}
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
// New creates a new Client for the given serverURL.
// TLS 1.2 is the minimum version enforced on all connections.
// If opts.CACertPath is set, that CA certificate is added to the trust pool.
func New(serverURL string, opts Options) (*Client, error) {
serverURL = strings.TrimRight(serverURL, "/")
// Security: never negotiate TLS < 1.2; this prevents POODLE/BEAST-class
// downgrade attacks against the token-bearing transport.
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if opts.CACertPath != "" {
pem, err := os.ReadFile(opts.CACertPath)
if err != nil {
return nil, fmt.Errorf("mciasgoclient: read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("mciasgoclient: no valid certs in CA file")
}
tlsCfg.RootCAs = pool
}
transport := &http.Transport{TLSClientConfig: tlsCfg}
c := &Client{
baseURL: serverURL,
http: &http.Client{Transport: transport},
token: opts.Token,
}
return c, nil
}
// Token returns the current bearer token (empty string if not logged in).
func (c *Client) Token() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
func (c *Client) setToken(tok string) {
c.mu.Lock()
defer c.mu.Unlock()
c.token = tok
}
func (c *Client) do(method, path string, body interface{}, out interface{}) error {
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("mciasgoclient: marshal request: %w", err)
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.baseURL+path, reqBody)
if err != nil {
return fmt.Errorf("mciasgoclient: build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Security: token is read under the lock and added to the Authorization
// header only; it is never written to any log or error message in this
// library.
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("mciasgoclient: request: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("mciasgoclient: read response: %w", err)
}
if resp.StatusCode >= 400 {
var errResp struct {
Error string `json:"error"`
}
_ = json.Unmarshal(respBytes, &errResp)
msg := errResp.Error
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return makeError(resp.StatusCode, msg)
}
if out != nil && len(respBytes) > 0 {
dec := json.NewDecoder(bytes.NewReader(respBytes))
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
return fmt.Errorf("mciasgoclient: decode response: %w", err)
}
}
return nil
}
func makeError(status int, msg string) error {
base := MciasError{StatusCode: status, Message: msg}
switch {
case status == 401:
return &MciasAuthError{base}
case status == 403:
return &MciasForbiddenError{base}
case status == 404:
return &MciasNotFoundError{base}
case status == 400:
return &MciasInputError{base}
case status == 409:
return &MciasConflictError{base}
default:
return &MciasServerError{base}
}
}
// ---------------------------------------------------------------------------
// API methods
// ---------------------------------------------------------------------------
// Health calls GET /v1/health. Returns nil if the server is healthy.
func (c *Client) Health() error {
return c.do(http.MethodGet, "/v1/health", nil, nil)
}
// GetPublicKey returns the server's Ed25519 public key in JWK format.
func (c *Client) GetPublicKey() (*PublicKey, error) {
var pk PublicKey
if err := c.do(http.MethodGet, "/v1/keys/public", nil, &pk); err != nil {
return nil, err
}
return &pk, nil
}
// Login authenticates with username and password. On success the token is
// stored in the Client and returned along with the expiry timestamp.
// totpCode may be empty for accounts without TOTP.
func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) {
req := map[string]string{"username": username, "password": password}
if totpCode != "" {
req["totp_code"] = totpCode
}
var resp struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
if err := c.do(http.MethodPost, "/v1/auth/login", req, &resp); err != nil {
return "", "", err
}
c.setToken(resp.Token)
return resp.Token, resp.ExpiresAt, nil
}
// Logout revokes the current token on the server and clears it from the client.
func (c *Client) Logout() error {
if err := c.do(http.MethodPost, "/v1/auth/logout", nil, nil); err != nil {
return err
}
c.setToken("")
return nil
}
// RenewToken exchanges the current token for a fresh one.
// The new token is stored in the client and returned.
func (c *Client) RenewToken() (token, expiresAt string, err error) {
var resp struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
if err := c.do(http.MethodPost, "/v1/auth/renew", map[string]string{}, &resp); err != nil {
return "", "", err
}
c.setToken(resp.Token)
return resp.Token, resp.ExpiresAt, nil
}
// ValidateToken validates a token string against the server.
// Returns claims; Valid is false (not an error) if the token is expired or
// revoked.
func (c *Client) ValidateToken(token string) (*TokenClaims, error) {
var claims TokenClaims
if err := c.do(http.MethodPost, "/v1/token/validate",
map[string]string{"token": token}, &claims); err != nil {
return nil, err
}
return &claims, nil
}
// CreateAccount creates a new account. accountType is "human" or "system".
// password is required for human accounts.
func (c *Client) CreateAccount(username, accountType, password string) (*Account, error) {
req := map[string]string{
"username": username,
"account_type": accountType,
}
if password != "" {
req["password"] = password
}
var acct Account
if err := c.do(http.MethodPost, "/v1/accounts", req, &acct); err != nil {
return nil, err
}
return &acct, nil
}
// ListAccounts returns all accounts. Requires admin role.
func (c *Client) ListAccounts() ([]Account, error) {
var accounts []Account
if err := c.do(http.MethodGet, "/v1/accounts", nil, &accounts); err != nil {
return nil, err
}
return accounts, nil
}
// GetAccount returns the account with the given ID. Requires admin role.
func (c *Client) GetAccount(id string) (*Account, error) {
var acct Account
if err := c.do(http.MethodGet, "/v1/accounts/"+id, nil, &acct); err != nil {
return nil, err
}
return &acct, nil
}
// UpdateAccount updates mutable account fields. Requires admin role.
// Pass an empty string for fields that should not be changed.
func (c *Client) UpdateAccount(id, status string) (*Account, error) {
req := map[string]string{}
if status != "" {
req["status"] = status
}
var acct Account
if err := c.do(http.MethodPatch, "/v1/accounts/"+id, req, &acct); err != nil {
return nil, err
}
return &acct, nil
}
// DeleteAccount soft-deletes the account with the given ID. Requires admin.
func (c *Client) DeleteAccount(id string) error {
return c.do(http.MethodDelete, "/v1/accounts/"+id, nil, nil)
}
// GetRoles returns the roles for accountID. Requires admin.
func (c *Client) GetRoles(accountID string) ([]string, error) {
var resp struct {
Roles []string `json:"roles"`
}
if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/roles", nil, &resp); err != nil {
return nil, err
}
return resp.Roles, nil
}
// SetRoles replaces the role set for accountID. Requires admin.
func (c *Client) SetRoles(accountID string, roles []string) error {
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/roles",
map[string][]string{"roles": roles}, nil)
}
// IssueServiceToken issues a long-lived token for a system account. Requires admin.
func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, err error) {
var resp struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
if err := c.do(http.MethodPost, "/v1/token/issue",
map[string]string{"account_id": accountID}, &resp); err != nil {
return "", "", err
}
return resp.Token, resp.ExpiresAt, nil
}
// RevokeToken revokes a token by JTI. Requires admin.
func (c *Client) RevokeToken(jti string) error {
return c.do(http.MethodDelete, "/v1/token/"+jti, nil, nil)
}
// GetPGCreds returns Postgres credentials for accountID. Requires admin.
func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
var creds PGCreds
if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/pgcreds", nil, &creds); err != nil {
return nil, err
}
return &creds, nil
}
// SetPGCreds stores Postgres credentials for accountID. Requires admin.
// The password is sent over TLS and encrypted at rest server-side.
func (c *Client) SetPGCreds(accountID, host string, port int, database, username, password string) error {
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/pgcreds", map[string]interface{}{
"host": host,
"port": port,
"database": database,
"username": username,
"password": password,
}, nil)
}