Files
mcias/internal/model/model.go
Kyle Isom 25417b24f4 Add FIDO2/WebAuthn passkey authentication
Phase 14: Full WebAuthn support for passwordless passkey login and
hardware security key 2FA.

- go-webauthn/webauthn v0.16.1 dependency
- WebAuthnConfig with RPID/RPOrigin/DisplayName validation
- Migration 000009: webauthn_credentials table
- DB CRUD with ownership checks and admin operations
- internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM
- REST: register begin/finish, login begin/finish, list, delete
- Web UI: profile enrollment, login passkey button, admin management
- gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs
- mciasdb: webauthn list/delete/reset subcommands
- OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema
- Policy: self-service enrollment rule, admin remove via wildcard
- Tests: DB CRUD, adapter round-trip, interface compliance
- Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14

Security: Credential IDs and public keys encrypted at rest with
AES-256-GCM via vault master key. Challenge ceremonies use 128-bit
nonces with 120s TTL in sync.Map. Sign counter validated on each
assertion to detect cloned authenticators. Password re-auth required
for registration (SEC-01 pattern). No credential material in API
responses or logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:12:59 -07:00

274 lines
10 KiB
Go

// Package model defines the shared data types used throughout MCIAS.
// These are pure data definitions with no external dependencies.
package model
import (
"fmt"
"time"
)
// AccountType distinguishes human interactive accounts from non-interactive
// service accounts.
type AccountType string
// AccountTypeHuman and AccountTypeSystem are the two valid account types.
const (
AccountTypeHuman AccountType = "human"
AccountTypeSystem AccountType = "system"
)
// AccountStatus represents the lifecycle state of an account.
type AccountStatus string
// AccountStatusActive, AccountStatusInactive, and AccountStatusDeleted are
// the valid account lifecycle states.
const (
AccountStatusActive AccountStatus = "active"
AccountStatusInactive AccountStatus = "inactive"
AccountStatusDeleted AccountStatus = "deleted"
)
// Account represents a user or service identity in MCIAS.
// Fields containing credential material (PasswordHash, TOTPSecretEnc) are
// never serialised into API responses — callers must explicitly omit them.
type Account struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
UUID string `json:"id"`
Username string `json:"username"`
AccountType AccountType `json:"account_type"`
Status AccountStatus `json:"status"`
PasswordHash string `json:"-"`
TOTPSecretEnc []byte `json:"-"`
TOTPSecretNonce []byte `json:"-"`
ID int64 `json:"-"`
TOTPRequired bool `json:"totp_required"`
}
// Allowlisted role names (DEF-10).
// Only these strings may be stored in account_roles. Extending the set of
// valid roles requires a code change, ensuring that typos such as "admim"
// are caught at grant time rather than silently creating a useless role.
const (
RoleAdmin = "admin"
RoleUser = "user"
RoleGuest = "guest"
RoleViewer = "viewer"
RoleEditor = "editor"
RoleCommenter = "commenter"
)
// allowedRoles is the compile-time set of recognised role names.
var allowedRoles = map[string]struct{}{
RoleAdmin: {},
RoleUser: {},
RoleGuest: {},
RoleViewer: {},
RoleEditor: {},
RoleCommenter: {},
}
// ValidateRole returns nil if role is an allowlisted role name, or an error
// describing the problem. Call this before writing to account_roles.
//
// Security (DEF-10): prevents admins from accidentally creating unmatchable
// roles (e.g. "admim") by enforcing a compile-time allowlist.
func ValidateRole(role string) error {
if _, ok := allowedRoles[role]; !ok {
return fmt.Errorf("model: unknown role %q; allowed roles: admin, user, guest, viewer, editor, commenter", role)
}
return nil
}
// Role is a string label assigned to an account to grant permissions.
type Role struct {
GrantedAt time.Time `json:"granted_at"`
GrantedBy *int64 `json:"-"`
Role string `json:"role"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
}
// TokenRecord tracks an issued JWT by its JTI for revocation purposes.
// The raw token string is never stored — only the JTI identifier.
type TokenRecord struct {
ExpiresAt time.Time `json:"expires_at"`
IssuedAt time.Time `json:"issued_at"`
CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
JTI string `json:"jti"`
RevokeReason string `json:"revoke_reason,omitempty"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
}
// IsRevoked reports whether the token has been explicitly revoked.
func (t *TokenRecord) IsRevoked() bool {
return t.RevokedAt != nil
}
// IsExpired reports whether the token is past its expiry time.
func (t *TokenRecord) IsExpired() bool {
return time.Now().After(t.ExpiresAt)
}
// SystemToken represents the current active service token for a system account.
type SystemToken struct {
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
JTI string `json:"jti"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
}
// PGCredential holds Postgres connection details for a system account.
// The password is encrypted at rest; PGPassword is only populated after
// 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 {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OwnerID *int64 `json:"-"`
ServiceAccountUUID string `json:"service_account_uuid,omitempty"`
PGUsername string `json:"username"`
PGPassword string `json:"-"`
ServiceUsername string `json:"service_username,omitempty"`
PGDatabase string `json:"database"`
PGHost string `json:"host"`
PGPasswordEnc []byte `json:"-"`
PGPasswordNonce []byte `json:"-"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
PGPort int `json:"port"`
}
// AuditEvent represents a single entry in the append-only audit log.
// Details must never contain credential material (passwords, tokens, secrets).
type AuditEvent struct {
EventTime time.Time `json:"event_time"`
ActorID *int64 `json:"-"`
TargetID *int64 `json:"-"`
EventType string `json:"event_type"`
IPAddress string `json:"ip_address,omitempty"`
Details string `json:"details,omitempty"`
ID int64 `json:"id"`
}
// Audit event type constants — exhaustive list, enforced at write time.
const (
EventLoginOK = "login_ok"
EventLoginFail = "login_fail"
EventLoginTOTPFail = "login_totp_fail"
EventTokenIssued = "token_issued"
EventTokenRenewed = "token_renewed"
EventTokenRevoked = "token_revoked"
EventTokenExpired = "token_expired"
EventAccountCreated = "account_created"
EventAccountUpdated = "account_updated"
EventAccountDeleted = "account_deleted"
EventRoleGranted = "role_granted"
EventRoleRevoked = "role_revoked"
EventTOTPEnrolled = "totp_enrolled"
EventTOTPRemoved = "totp_removed"
EventPGCredAccessed = "pgcred_accessed"
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
EventVaultSealed = "vault_sealed"
EventVaultUnsealed = "vault_unsealed"
EventTagAdded = "tag_added"
EventTagRemoved = "tag_removed"
EventPolicyRuleCreated = "policy_rule_created"
EventPolicyRuleUpdated = "policy_rule_updated"
EventPolicyRuleDeleted = "policy_rule_deleted"
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
EventPasswordChanged = "password_changed"
EventTokenDelegateGranted = "token_delegate_granted"
EventTokenDelegateRevoked = "token_delegate_revoked"
EventWebAuthnEnrolled = "webauthn_enrolled"
EventWebAuthnRemoved = "webauthn_removed"
EventWebAuthnLoginOK = "webauthn_login_ok"
EventWebAuthnLoginFail = "webauthn_login_fail"
)
// ServiceAccountDelegate records that a specific account has been granted
// permission to issue tokens for a given system account. Only admins can
// add or remove delegates; delegates can issue/rotate tokens for that specific
// system account and nothing else.
type ServiceAccountDelegate struct {
GrantedAt time.Time `json:"granted_at"`
GrantedBy *int64 `json:"-"`
GranteeUUID string `json:"grantee_id"`
GranteeName string `json:"grantee_username"`
ID int64 `json:"-"`
AccountID int64 `json:"-"`
GranteeID int64 `json:"-"`
}
// WebAuthnCredential holds a stored WebAuthn/passkey credential.
// Credential IDs and public keys are encrypted at rest with AES-256-GCM;
// decrypted values must never be logged or included in API responses.
type WebAuthnCredential struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Name string `json:"name"`
AAGUID string `json:"aaguid"`
Transports string `json:"transports,omitempty"`
CredentialIDEnc []byte `json:"-"`
CredentialIDNonce []byte `json:"-"`
PublicKeyEnc []byte `json:"-"`
PublicKeyNonce []byte `json:"-"`
ID int64 `json:"id"`
AccountID int64 `json:"-"`
SignCount uint32 `json:"sign_count"`
Discoverable bool `json:"discoverable"`
}
// PolicyRuleRecord is the database representation of a policy rule.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns.
// NotBefore and ExpiresAt define an optional validity window; nil means no
// constraint (always active / never expires).
type PolicyRuleRecord struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
NotBefore *time.Time `json:"not_before,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedBy *int64 `json:"-"`
Description string `json:"description"`
RuleJSON string `json:"rule_json"`
ID int64 `json:"id"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
}