// 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"` } // TOTPEnrollResponse is returned by EnrollTOTP. type TOTPEnrollResponse struct { Secret string `json:"secret"` OTPAuthURI string `json:"otpauth_uri"` } // AuditEvent is a single entry in the audit log. type AuditEvent struct { ID int `json:"id"` EventType string `json:"event_type"` EventTime string `json:"event_time"` ActorID string `json:"actor_id,omitempty"` TargetID string `json:"target_id,omitempty"` IPAddress string `json:"ip_address"` Details string `json:"details,omitempty"` } // AuditListResponse is returned by ListAudit. type AuditListResponse struct { Events []AuditEvent `json:"events"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } // AuditFilter holds optional filter parameters for ListAudit. type AuditFilter struct { Limit int Offset int EventType string ActorID string } // PolicyRuleBody holds the match conditions and effect of a policy rule. // All fields except Effect are optional; an omitted field acts as a wildcard. type PolicyRuleBody struct { Effect string `json:"effect"` Roles []string `json:"roles,omitempty"` AccountTypes []string `json:"account_types,omitempty"` SubjectUUID string `json:"subject_uuid,omitempty"` Actions []string `json:"actions,omitempty"` ResourceType string `json:"resource_type,omitempty"` OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"` ServiceNames []string `json:"service_names,omitempty"` RequiredTags []string `json:"required_tags,omitempty"` } // PolicyRule is a complete operator-defined policy rule as returned by the API. type PolicyRule struct { ID int `json:"id"` Priority int `json:"priority"` Description string `json:"description"` Rule PolicyRuleBody `json:"rule"` Enabled bool `json:"enabled"` NotBefore string `json:"not_before,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // CreatePolicyRuleRequest holds the parameters for creating a policy rule. type CreatePolicyRuleRequest struct { Description string `json:"description"` Priority int `json:"priority,omitempty"` Rule PolicyRuleBody `json:"rule"` NotBefore string `json:"not_before,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` } // UpdatePolicyRuleRequest holds the parameters for updating a policy rule. // All fields are optional; omitted fields are left unchanged. // Set ClearNotBefore or ClearExpiresAt to true to remove those constraints. type UpdatePolicyRuleRequest struct { Description string `json:"description,omitempty"` Priority *int `json:"priority,omitempty"` Enabled *bool `json:"enabled,omitempty"` Rule *PolicyRuleBody `json:"rule,omitempty"` NotBefore string `json:"not_before,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` ClearNotBefore bool `json:"clear_not_before,omitempty"` ClearExpiresAt bool `json:"clear_expires_at,omitempty"` } // --------------------------------------------------------------------------- // 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 — Public // --------------------------------------------------------------------------- // 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 } // 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 } // --------------------------------------------------------------------------- // API methods — Authenticated // --------------------------------------------------------------------------- // 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 } // EnrollTOTP begins TOTP enrollment for the authenticated account. // Returns a base32 secret and an otpauth:// URI for QR-code generation. // The secret is shown once; it is not retrievable after this call. // TOTP is not enforced until confirmed via ConfirmTOTP. func (c *Client) EnrollTOTP() (*TOTPEnrollResponse, error) { var resp TOTPEnrollResponse if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", nil, &resp); err != nil { return nil, err } return &resp, nil } // ConfirmTOTP completes TOTP enrollment by verifying the current code against // the pending secret. On success, TOTP becomes required for all future logins. func (c *Client) ConfirmTOTP(code string) error { return c.do(http.MethodPost, "/v1/auth/totp/confirm", map[string]string{"code": code}, nil) } // ChangePassword changes the password of the currently authenticated human // account. currentPassword is required to prevent token-theft attacks. // On success, all active sessions except the caller's are revoked. // // Security: both passwords are transmitted over TLS only; the server verifies // currentPassword with constant-time comparison before accepting the change. func (c *Client) ChangePassword(currentPassword, newPassword string) error { return c.do(http.MethodPut, "/v1/auth/password", map[string]string{ "current_password": currentPassword, "new_password": newPassword, }, nil) } // --------------------------------------------------------------------------- // API methods — Admin: Auth // --------------------------------------------------------------------------- // RemoveTOTP clears TOTP enrollment for the given account (admin). // Use for account recovery when a user has lost their TOTP device. func (c *Client) RemoveTOTP(accountID string) error { return c.do(http.MethodDelete, "/v1/auth/totp", map[string]string{"account_id": accountID}, nil) } // --------------------------------------------------------------------------- // API methods — Admin: Accounts // --------------------------------------------------------------------------- // 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 } // 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 } // 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 (currently only status). // Requires admin role. Returns nil on success (HTTP 204). func (c *Client) UpdateAccount(id, status string) error { req := map[string]string{} if status != "" { req["status"] = status } return c.do(http.MethodPatch, "/v1/accounts/"+id, req, 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) } // AdminSetPassword resets a human account's password without requiring the // current password. Requires admin. All active sessions for the target account // are revoked on success. func (c *Client) AdminSetPassword(accountID, newPassword string) error { return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/password", map[string]string{"new_password": newPassword}, nil) } // GetAccountTags returns the current tag set for an account. Requires admin. func (c *Client) GetAccountTags(accountID string) ([]string, error) { var resp struct { Tags []string `json:"tags"` } if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/tags", nil, &resp); err != nil { return nil, err } return resp.Tags, nil } // SetAccountTags replaces the full tag set for an account atomically. // Pass an empty slice to clear all tags. Requires admin. func (c *Client) SetAccountTags(accountID string, tags []string) ([]string, error) { var resp struct { Tags []string `json:"tags"` } if err := c.do(http.MethodPut, "/v1/accounts/"+accountID+"/tags", map[string][]string{"tags": tags}, &resp); err != nil { return nil, err } return resp.Tags, nil } // --------------------------------------------------------------------------- // API methods — Admin: Tokens // --------------------------------------------------------------------------- // 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) } // --------------------------------------------------------------------------- // API methods — Admin: Credentials // --------------------------------------------------------------------------- // 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) } // --------------------------------------------------------------------------- // API methods — Admin: Audit // --------------------------------------------------------------------------- // ListAudit retrieves audit log entries, newest first. Requires admin. // f may be zero-valued to use defaults (limit=50, offset=0, no filter). func (c *Client) ListAudit(f AuditFilter) (*AuditListResponse, error) { path := "/v1/audit?" if f.Limit > 0 { path += fmt.Sprintf("limit=%d&", f.Limit) } if f.Offset > 0 { path += fmt.Sprintf("offset=%d&", f.Offset) } if f.EventType != "" { path += fmt.Sprintf("event_type=%s&", f.EventType) } if f.ActorID != "" { path += fmt.Sprintf("actor_id=%s&", f.ActorID) } path = strings.TrimRight(path, "&?") var resp AuditListResponse if err := c.do(http.MethodGet, path, nil, &resp); err != nil { return nil, err } return &resp, nil } // --------------------------------------------------------------------------- // API methods — Admin: Policy // --------------------------------------------------------------------------- // ListPolicyRules returns all operator-defined policy rules ordered by // priority (ascending). Requires admin. func (c *Client) ListPolicyRules() ([]PolicyRule, error) { var rules []PolicyRule if err := c.do(http.MethodGet, "/v1/policy/rules", nil, &rules); err != nil { return nil, err } return rules, nil } // CreatePolicyRule creates a new policy rule. Requires admin. func (c *Client) CreatePolicyRule(req CreatePolicyRuleRequest) (*PolicyRule, error) { var rule PolicyRule if err := c.do(http.MethodPost, "/v1/policy/rules", req, &rule); err != nil { return nil, err } return &rule, nil } // GetPolicyRule returns a single policy rule by integer ID. Requires admin. func (c *Client) GetPolicyRule(id int) (*PolicyRule, error) { var rule PolicyRule if err := c.do(http.MethodGet, fmt.Sprintf("/v1/policy/rules/%d", id), nil, &rule); err != nil { return nil, err } return &rule, nil } // UpdatePolicyRule updates one or more fields of an existing policy rule. // Requires admin. func (c *Client) UpdatePolicyRule(id int, req UpdatePolicyRuleRequest) (*PolicyRule, error) { var rule PolicyRule if err := c.do(http.MethodPatch, fmt.Sprintf("/v1/policy/rules/%d", id), req, &rule); err != nil { return nil, err } return &rule, nil } // DeletePolicyRule permanently deletes a policy rule. Requires admin. func (c *Client) DeletePolicyRule(id int) error { return c.do(http.MethodDelete, fmt.Sprintf("/v1/policy/rules/%d", id), nil, nil) }