Documentation
¶
Overview ¶
Package db manages the database connection, migrations, and encryption for the Arkeep server. It supports SQLite (via modernc pure-Go driver, no CGO required) and PostgreSQL. Migrations are embedded in the binary and applied automatically on startup via golang-migrate.
Index ¶
- Constants
- func InitEncryption(key []byte) error
- func LockModeFor(jobType string) string
- func New(cfg Config) (*gorm.DB, error)
- type Agent
- type AuditLog
- type Base
- type Config
- type Destination
- type DestinationLock
- type EncryptedString
- type Job
- type JobDestination
- type JobDestinationCommand
- type JobLog
- type JobRetentionTag
- type Notification
- type NotificationDelivery
- type OIDCProvider
- type PasswordChangeChallenge
- type PasswordResetToken
- type Policy
- type PolicyDestination
- type RecoveryCode
- type RefreshToken
- type Setting
- type Snapshot
- type SoftDelete
- type TwoFactorChallenge
- type User
Constants ¶
const ( LockExclusive = "exclusive" )
Destination lock modes (issue #319). A backup holds its destinations shared: restic's own lock for a backup is non-exclusive, so backups run side by side. A retention sweep or an integrity check holds its destination exclusive.
const ( NotifyInherit = "inherit" // follow the global notification setting NotifyAlways = "always" NotifyNever = "never" )
Values of Policy.NotifyOnSuccess / Policy.NotifyOnFailure.
Variables ¶
This section is empty.
Functions ¶
func InitEncryption ¶
InitEncryption sets the AES-256 key used to encrypt and decrypt sensitive fields at rest. key must be exactly 32 bytes (AES-256).
Call this once during application startup, before calling db.New:
if err := db.InitEncryption([]byte(os.Getenv("ARKEEP_SECRET_KEY"))); err != nil {
log.Fatal(err)
}
func LockModeFor ¶
LockModeFor returns the mode a job of jobType holds its destinations in.
Types ¶
type Agent ¶
type Agent struct {
SoftDelete
Name string `gorm:"not null"`
Hostname string `gorm:"not null"`
IPAddress string `gorm:"not null;default:''"`
OS string `gorm:"not null;default:''"`
Arch string `gorm:"not null;default:''"`
Version string `gorm:"not null;default:''"`
Status string `gorm:"not null;default:'offline'"` // "online", "offline", "error"
LastSeenAt *time.Time
// DockerAvailable is true when the agent can reach the Docker daemon on its host.
// Advertised by the agent in the Register RPC via AgentCapabilities.docker.
// Used by the GUI to show or hide the Docker volume source option in the policy form.
DockerAvailable bool `gorm:"not null;default:false"`
// CertFingerprint is the hex SHA-256 of the mTLS client certificate the
// agent is bound to; nil until its first registration with one. Read-only
// for GORM so that Save on a stale copy can never undo a binding: it is
// written only by AgentRepository.BindCertFingerprint/ResetCertFingerprint.
CertFingerprint *string `gorm:"->"`
}
Agent represents a registered backup agent running on a remote machine. Agents connect to the server via a persistent gRPC stream (pull pattern) and do not expose any ports. The RegistrationToken is used only during the initial handshake and is cleared after successful registration.
type AuditLog ¶
type AuditLog struct {
ID uuid.UUID `gorm:"type:text;primaryKey"`
CreatedAt time.Time `gorm:"not null"`
UserID uuid.UUID `gorm:"type:text;not null;index"`
UserEmail string `gorm:"not null"`
Action string `gorm:"not null;index"` // e.g. "policy.update", "snapshot.restore"
ResourceType string `gorm:"not null;default:''"`
ResourceID string `gorm:"type:text;not null;default:''"`
Details string `gorm:"type:text;not null;default:'{}'"` // JSON
IPAddress string `gorm:"not null;default:''"`
}
AuditLog records every significant mutation performed via the API: who did it, what was changed, when, and from which IP address. The table is append-only — records are never updated or deleted. user_email is stored denormalized so the log remains readable even if the user account is later deleted.
func (*AuditLog) BeforeCreate ¶
BeforeCreate generates a UUID v7 for new audit records (no UpdatedAt — append-only).
type Base ¶
type Base struct {
ID uuid.UUID `gorm:"type:text;primaryKey"`
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}
base contains the common fields shared by all models. ID uses UUID v7 (time-ordered) for efficient B-tree indexing and natural chronological ordering without a separate created_at sort. CreatedAt and UpdatedAt are managed automatically by GORM.
type Config ¶
type Config struct {
Driver string // "sqlite" or "postgres"
DSN string
Logger *zap.Logger
LogLevel gormlogger.LogLevel
}
Config holds the configuration required to open a database connection. Driver defaults to "sqlite" if left empty.
type Destination ¶
type Destination struct {
SoftDelete
Name string `gorm:"not null"`
Type string `gorm:"not null"` // "local", "s3", "sftp", "rest", "rclone"
Credentials EncryptedString `gorm:"type:text"` // JSON, encrypted
Config string `gorm:"type:text;default:'{}'"` // JSON, not sensitive
Enabled bool `gorm:"not null;default:true"`
// RepoSizeBytes is the real deduplicated on-disk size of this destination's
// restic repository (from `restic stats --mode raw-data`), refreshed after
// each backup. Zero until the first backup or import completes.
RepoSizeBytes int64 `gorm:"not null;default:0"`
RepoSizeUpdatedAt *time.Time `gorm:""`
// RepoInitializedAt is when the restic repository was first known to
// exist (the first job or snapshot sync that succeeded against it); nil
// until then. Once set, a backup never runs `restic init` again, so a
// repository that has gone missing — an unmounted disk, an emptied bucket —
// fails the backup instead of being replaced by an empty one. Read-only
// for GORM so that Save on a stale copy can never set or clear it: it is
// written only by DestinationRepository.MarkRepoInitialized and
// ResetRepoInitialized.
RepoInitializedAt *time.Time `gorm:"->"`
// RepoPassword is the Restic repository password. Originally populated
// only for destinations whose snapshots were imported from a pre-existing
// repository (browse/restore of those has no policy to take the password
// from); now also the password the retention agent uses to unlock this
// destination's repository for a standalone retention sweep, backfilled
// from whichever policy was attached to this destination at the time
// retention moved here (see the one-time migration backfill in
// cmd/server/main.go). Any policy attached to a given destination shares
// the same physical repository, so any one of their passwords is correct.
RepoPassword EncryptedString `gorm:"type:text"`
// Retention — one configuration per destination, applied uniformly to
// every policy's own snapshot-tag pool here (see policy_destinations and
// the "policy:<id>" restic tag convention). Moved off Policy so retention
// runs on its own schedule, detached from any single backup job — see
// server/internal/retentionscheduler.
RetentionLast int `gorm:"not null;default:0"`
RetentionHourly int `gorm:"not null;default:0"`
RetentionDaily int `gorm:"not null;default:0"`
RetentionWeekly int `gorm:"not null;default:0"`
RetentionMonthly int `gorm:"not null;default:0"`
RetentionYearly int `gorm:"not null;default:0"`
RetentionSchedule string `gorm:"not null;default:''"` // cron expression; "" = unconfigured
RetentionEnabled bool `gorm:"not null;default:false"`
// RetentionAgentID is the connected agent responsible for running this
// destination's retention sweeps. Explicit and admin-chosen — a
// destination has no other owning agent (any number of policies, on any
// agents, can write here). nil = not yet assigned.
RetentionAgentID *uuid.UUID `gorm:"type:text"`
// AppendOnly marks a repository that can never support `restic forget
// --prune` (e.g. WORM/object-lock storage). The retention scheduler must
// never even attempt a sweep here.
AppendOnly bool `gorm:"not null;default:false"`
// RetentionNeedsReview is set by the one-time migration backfill when this
// destination was shared by 2+ policies with no single retention
// configuration to inherit unambiguously. Cleared the first time an admin
// explicitly saves retention config for this destination.
RetentionNeedsReview bool `gorm:"not null;default:false"`
// Integrity check (restic check) — run on its own schedule by
// RetentionAgentID, which acts as the destination's maintenance agent for
// both retention and check. See server/internal/checkscheduler.
CheckEnabled bool `gorm:"not null;default:false"`
CheckSchedule string `gorm:"not null;default:''"` // cron expression; "" = unconfigured
CheckMode string `gorm:"not null;default:'subset'"` // structure | subset | full
CheckSubsetPercent int `gorm:"not null;default:5"` // used by mode "subset" only
// Outcome of the most recent check job. LastCheckStatus is "" when no
// check has completed yet, otherwise the job's terminal status.
LastCheckAt *time.Time
LastCheckStatus string `gorm:"not null;default:''"`
LastCheckJobID *uuid.UUID `gorm:"type:text"`
}
Destination represents a backup storage target. Credentials are encrypted at rest via EncryptedString. The Config field holds provider-specific settings serialized as JSON (e.g. bucket name, endpoint, region for S3).
type DestinationLock ¶
type DestinationLock struct {
DestinationID uuid.UUID `gorm:"type:text;primaryKey"`
JobID uuid.UUID `gorm:"type:text;primaryKey"`
Mode string `gorm:"not null"` // LockShared | LockExclusive
AcquiredAt time.Time `gorm:"not null"`
}
DestinationLock is one job's hold on a destination's repository: the server-side gate that keeps a retention sweep or integrity check from running alongside anything else on the same repository, instead of racing restic's own lock file (issues #130, #319). Deleting the job drops its holds.
type EncryptedString ¶
type EncryptedString string
EncryptedString is a string type that is transparently encrypted with AES-256-GCM before being written to the database, and decrypted after being read. Use it for any sensitive field (credentials, passwords, tokens).
The value stored in the database is a base64-encoded string in the format:
base64(nonce + ciphertext)
An empty EncryptedString is stored as an empty string without encryption.
func (*EncryptedString) Scan ¶
func (e *EncryptedString) Scan(value interface{}) error
Scan implements sql.Scanner. Called by GORM after reading from the database. Decodes the base64 string and decrypts it with AES-256-GCM.
type Job ¶
type Job struct {
Base
// PolicyID is nil for a restore job started from an imported snapshot:
// such a snapshot belongs to no policy.
PolicyID *uuid.UUID `gorm:"type:text;index"`
AgentID uuid.UUID `gorm:"type:text;not null;index"`
Type string `gorm:"not null;default:'backup'"` // "backup", "restore"
Status string `gorm:"not null;default:'pending'"` // "pending", "waiting", "running", "succeeded", "failed", "cancelled", "interrupted"
StartedAt *time.Time
EndedAt *time.Time
Error string `gorm:"type:text;default:''"` // populated on failure
// ResumeOfJobID is the interrupted job this run resumes, nil for a normal
// run. No foreign key: the original job may be removed by job retention.
ResumeOfJobID *uuid.UUID `gorm:"type:text"`
// ResumeAttempt counts consecutive resumes, so a host that dies at the same
// point every time stops being retried. Zero for a normal run.
ResumeAttempt int `gorm:"not null;default:0"`
}
Job represents a single backup execution triggered by the scheduler or manually. Status transitions: pending -> running -> succeeded | failed | cancelled | interrupted.
"interrupted" means the agent vanished while the job was running (host shut down, sleep, network loss), as opposed to "failed", which is a real error reported by the agent. Only interrupted jobs are eligible for automatic resume.
type JobDestination ¶
type JobDestination struct {
Base
JobID uuid.UUID `gorm:"type:text;not null;index"`
DestinationID uuid.UUID `gorm:"type:text;not null;index"`
Status string `gorm:"not null;default:'pending'"` // mirrors Job.Status
SnapshotID string `gorm:"default:''"` // opaque ID returned by the backup engine
SizeBytes int64 `gorm:"default:0"`
StartedAt *time.Time
EndedAt *time.Time
Error string `gorm:"type:text;default:''"`
}
JobDestination tracks the result of a backup job for each individual destination. A job can partially succeed if some destinations fail.
type JobDestinationCommand ¶
type JobDestinationCommand struct {
Base
JobID uuid.UUID `gorm:"type:text;not null;index"`
DestinationID uuid.UUID `gorm:"type:text;not null;index"`
// SourceName is the policy source's name — also the restic
// --stdin-filename and the suffix of its retention tag.
SourceName string `gorm:"not null"`
Status string `gorm:"not null;default:'pending'"`
SnapshotID string `gorm:"default:''"`
SizeBytes int64 `gorm:"default:0"`
StartedAt *time.Time
EndedAt *time.Time
Error string `gorm:"type:text;default:''"`
}
JobDestinationCommand tracks the result of one command-type source backed up to one destination. A command source is its own restic invocation and its own snapshot, so a job can have several of these per destination — which is why they cannot live in JobDestination (UNIQUE(job_id, destination_id), one scalar snapshot_id).
type JobLog ¶
type JobLog struct {
Base
JobID uuid.UUID `gorm:"type:text;not null;index"`
Level string `gorm:"not null"` // "info", "warn", "error"
Message string `gorm:"type:text;not null"`
Timestamp time.Time `gorm:"not null;index"`
}
JobLog stores structured log lines emitted during a job execution. Logs are flushed to the database in batches during execution so that the GUI can show partial logs even for in-progress jobs.
type JobRetentionTag ¶
type JobRetentionTag struct {
Base
JobID uuid.UUID `gorm:"type:text;not null;index"`
DestinationID uuid.UUID `gorm:"type:text;not null;index"`
// Tag is the restic --tag value swept, e.g. "policy:<id>" or
// "policy:<id>:command:<name>".
Tag string `gorm:"not null"`
Status string `gorm:"not null;default:'pending'"` // "pending", "running", "succeeded", "failed", "skipped"
StartedAt *time.Time
EndedAt *time.Time
Error string `gorm:"type:text;default:''"`
}
JobRetentionTag tracks the result of one restic `forget --prune --tag` sweep within a standalone retention job (JOB_TYPE_FORGET). A retention job covers one Destination but must sweep every policy's own tag pool there independently (see agent/internal/restic/wrapper.go's Forget, which refuses to run unscoped), so a job can have several of these per destination — no snapshot/size fields, unlike JobDestinationCommand: a forget never creates a snapshot.
type Notification ¶
type Notification struct {
Base
UserID uuid.UUID `gorm:"type:text;not null;index"`
Type string `gorm:"not null"` // "job_success", "job_failure", "agent_offline", etc.
Title string `gorm:"not null"`
Body string `gorm:"type:text;not null"`
ReadAt *time.Time
Payload string `gorm:"type:text;default:'{}'"` // JSON, extra context for the frontend
}
Notification stores in-app notifications delivered to users via WebSocket. Read notifications are kept for 30 days and then purged by a background job.
type NotificationDelivery ¶
type NotificationDelivery struct {
Base
NotificationID uuid.UUID `gorm:"type:text;not null;index"`
Type string `gorm:"not null"` // "email" | "webhook"
Status string `gorm:"not null;default:'pending'"` // "pending" | "sent" | "exhausted"
Attempts int `gorm:"not null;default:0"`
LastError string `gorm:"type:text;not null;default:''"`
NextRetryAt *time.Time // nil = ready to process immediately
}
NotificationDelivery tracks the delivery state of a single notification over a single external channel (email or webhook). Each Notification can have at most one NotificationDelivery row per channel type.
Status transitions:
pending → sent (delivery succeeded) pending → pending (retry scheduled after backoff) pending → exhausted (max 3 retries exceeded, no further attempts)
Rows are automatically removed when the parent Notification is deleted (ON DELETE CASCADE on the foreign key).
func (NotificationDelivery) TableName ¶
func (NotificationDelivery) TableName() string
TableName maps to the migration-created table name.
type OIDCProvider ¶
type OIDCProvider struct {
Base
Name string `gorm:"not null"`
Issuer string `gorm:"not null"`
ClientID string `gorm:"not null"`
ClientSecret EncryptedString `gorm:"type:text;not null"`
Scopes string `gorm:"not null;default:'openid email profile'"` // space-separated
Enabled bool `gorm:"not null;default:false"`
// GroupsClaim names the ID token / UserInfo claim holding the user's
// groups. Required when AllowedGroups or AdminGroups is set.
GroupsClaim string `gorm:"not null;default:''"`
// AllowedGroups restricts sign-in to members of at least one of these
// groups; empty allows every user of the IdP.
AllowedGroups string `gorm:"not null;default:''"` // comma-separated
// AdminGroups grants the admin role to their members and the user role to
// everyone else, re-evaluated on every login; empty leaves roles alone.
AdminGroups string `gorm:"not null;default:''"` // comma-separated
// SessionMaxHours caps how long a session started through this provider
// lasts before the user must sign in at the IdP again.
SessionMaxHours int `gorm:"not null;default:12"`
}
OIDCProvider stores the configuration for an external OIDC identity provider. ClientSecret is encrypted at rest. Multiple providers are supported. The callback URL is computed server-side as {base_url}/auth/oidc/callback and is not stored in the database.
func (OIDCProvider) TableName ¶
func (OIDCProvider) TableName() string
TableName overrides GORM's default naming convention, which would produce "o_id_c_providers" by splitting each uppercase letter as a word boundary.
type PasswordChangeChallenge ¶
type PasswordChangeChallenge struct {
Base
UserID uuid.UUID `gorm:"type:text;not null;index"`
TokenHash string `gorm:"not null;index"` // SHA-256 hex of the raw token
ExpiresAt time.Time `gorm:"not null"`
UsedAt *time.Time // nil = still usable
}
PasswordChangeChallenge is the short-lived interim state of a login whose account must change its password: the password (and the second factor, when enabled) has been verified, but no session is issued until a new password is set. Only the SHA-256 hash of the token is stored. It is single-use (UsedAt).
type PasswordResetToken ¶
type PasswordResetToken struct {
Base
UserID uuid.UUID `gorm:"type:text;not null;index"`
TokenHash string `gorm:"not null;index"` // SHA-256 hex of the raw token
ExpiresAt time.Time `gorm:"not null"`
UsedAt *time.Time // nil = not yet used
}
PasswordResetToken stores a hashed, single-use token for the self-service password reset flow. Only local accounts can reset their password — OIDC users are managed by their identity provider. The raw token is never stored: only its SHA-256 hash. Tokens expire after a short window and are consumed (UsedAt set) on the first successful reset.
type Policy ¶
type Policy struct {
SoftDelete
Name string `gorm:"not null"`
AgentID uuid.UUID `gorm:"type:text;not null;index"`
Schedule string `gorm:"not null"` // cron expression
Enabled bool `gorm:"not null;default:true"`
Sources string `gorm:"type:text;not null"` // JSON array of source paths
RepoPassword EncryptedString `gorm:"type:text;not null"` // Restic repository password
HookPreBackup string `gorm:"type:text;default:''"` // shell command, optional
HookPostBackup string `gorm:"type:text;default:''"` // shell command, optional
ExcludePatterns string `gorm:"type:text;default:'[]'"` // JSON array of --exclude patterns
// ResumeInterrupted enables automatic resume of a backup whose agent
// disconnected mid-run. New policies default to true — restic reuses the
// packs already uploaded, so resuming only transfers what is missing.
//
// No `default` tag on purpose: GORM omits a zero-valued field from the INSERT
// when it knows the column has a default, which would silently turn an
// explicit false into true. The DEFAULT TRUE in the migration is what
// backfills pre-existing rows.
ResumeInterrupted bool `gorm:"not null"`
// NotifyOnSuccess / NotifyOnFailure override the global job_success /
// job_failure notification toggles for this policy's jobs. One of the
// Notify* values below.
NotifyOnSuccess string `gorm:"type:text;not null"`
NotifyOnFailure string `gorm:"type:text;not null"`
// HealthcheckURL is an optional Healthchecks.io ping URL; backup jobs ping
// it on start, success and failure (see internal/healthcheck). Empty
// disables the pings.
HealthcheckURL string `gorm:"type:text;not null;default:''"`
LastRunAt *time.Time
NextRunAt *time.Time
}
Policy defines what to back up, when, and how. It is associated with one agent and one or more destinations via PolicyDestination. The schedule uses standard cron expression syntax (e.g. "0 2 * * *" for 2 AM daily).
Association fields are intentionally absent from this struct. GORM cannot resolve foreign keys when the primary key is uuid.UUID (a custom type). Related records are loaded via explicit queries in the repository layer (see repository/policy.go: GetByIDWithDestinations).
type PolicyDestination ¶
type PolicyDestination struct {
Base
PolicyID uuid.UUID `gorm:"type:text;not null;index"`
DestinationID uuid.UUID `gorm:"type:text;not null;index"`
Priority int `gorm:"not null;default:0"`
}
PolicyDestination is the join table between Policy and Destination. Priority determines the order in which destinations are tried (lower = first). This enables 3-2-1 backup rules with multiple destinations per policy.
type RecoveryCode ¶
type RecoveryCode struct {
Base
UserID uuid.UUID `gorm:"type:text;not null;index"`
CodeHash string `gorm:"not null;index"` // SHA-256 hex of the normalised code
UsedAt *time.Time // nil = not yet used
}
RecoveryCode is a single-use fallback credential issued when a user enables two-factor authentication. Only the SHA-256 hash of the normalised code is stored; the raw codes are shown to the user exactly once.
type RefreshToken ¶
type RefreshToken struct {
Base
UserID uuid.UUID `gorm:"type:text;not null;index"`
TokenHash string `gorm:"not null;uniqueIndex"` // SHA-256 hex of the raw token
ExpiresAt time.Time `gorm:"not null;index"`
RevokedAt *time.Time
UserAgent string
IPAddress string
// SessionExpiresAt is the absolute deadline of an OIDC session, copied on
// every rotation so refreshing cannot extend it. nil = no deadline (local
// logins).
SessionExpiresAt *time.Time
}
RefreshToken stores a hashed refresh token associated with a user session. The raw token is never stored — only its SHA-256 hash. Tokens are rotated on every use and expire after 7 days.
type Setting ¶
type Setting struct {
Key string `gorm:"primaryKey"`
Value EncryptedString `gorm:"type:text;not null"`
UpdatedAt time.Time `gorm:"not null;autoUpdateTime"`
}
Setting is a generic key-value configuration entry stored in the database. Keys are namespaced by convention (e.g. "smtp.host", "webhook.url"). Sensitive values (e.g. "smtp.password") are encrypted at the application layer via EncryptedString before being persisted.
Setting does not embed base because it uses a string primary key (the key itself) rather than a UUID, and does not need CreatedAt.
type Snapshot ¶
type Snapshot struct {
Base
// PolicyID and JobID are nil for snapshots imported from a pre-existing
// repository: they belong to a destination but were not produced by any
// backup run of any policy.
PolicyID *uuid.UUID `gorm:"type:text;index"`
DestinationID uuid.UUID `gorm:"type:text;not null;index"`
JobID *uuid.UUID `gorm:"type:text;index"`
SnapshotID string `gorm:"not null;index"` // opaque ID from the backup engine
// SizeBytes is the real footprint this backup added to the repository
// (restic data_added_packed), not the logical source size — so it reconciles
// with the destination's real repo size and never double-counts. Zero when
// the backup added nothing new (e.g. re-backup of unchanged data).
SizeBytes int64 `gorm:"default:0"`
FileCount int64 `gorm:"default:0"`
Tags string `gorm:"type:text;default:'[]'"` // JSON array
Sources string `gorm:"type:text;default:'[]'"` // JSON array of paths backed up
Hostname string `gorm:"not null;default:''"`
IsImported bool `gorm:"not null;default:false"`
SnapshotAt time.Time `gorm:"not null;index"`
}
Snapshot represents a point-in-time backup recorded by the backup engine. Snapshots are synced from the engine after each successful job and cached in the database for fast listing and filtering without hitting the engine.
type SoftDelete ¶
softDelete extends base with a nullable DeletedAt field for soft deletion. GORM automatically filters out soft-deleted records from all queries unless Unscoped() is used explicitly.
type TwoFactorChallenge ¶
type TwoFactorChallenge struct {
Base
UserID uuid.UUID `gorm:"type:text;not null;index"`
TokenHash string `gorm:"not null;index"` // SHA-256 hex of the raw token
Attempts int `gorm:"not null;default:0"`
ExpiresAt time.Time `gorm:"not null"`
UsedAt *time.Time // nil = still usable
// SessionExpiresAt carries the OIDC session deadline to the tokens issued
// once the code is verified. nil for local logins.
SessionExpiresAt *time.Time
}
TwoFactorChallenge is the short-lived interim state of a two-step login: the password has been verified but the TOTP code has not. Only the SHA-256 hash of the challenge token is stored — the raw token is returned to the client once and echoed back on the second step. Attempts counts failed codes; the challenge is consumed (UsedAt set) on success or after too many failures.
type User ¶
type User struct {
Base
Email string `gorm:"uniqueIndex;not null"`
Password EncryptedString `gorm:"type:text"` // empty for OIDC users
DisplayName string `gorm:"not null"`
Role string `gorm:"not null;default:'user'"` // "admin" or "user"
IsActive bool `gorm:"not null;default:true"` // false = account disabled
OIDCProvider string `gorm:"column:oidc_provider;default:''"` // provider ID if OIDC user
OIDCSub string `gorm:"column:oidc_sub;default:''"` // subject claim from OIDC token
LastLoginAt *time.Time
// TOTPSecret is the base32 TOTP shared secret, encrypted at rest. Empty
// means no secret. A non-empty secret with TwoFactorEnabled false is a
// pending enrollment that was never confirmed.
TOTPSecret EncryptedString `gorm:"type:text"`
TwoFactorEnabled bool `gorm:"not null;default:false"`
// MustChangePassword stops a local login before any session is issued
// and asks for a new password. Set by an admin with Set password.
MustChangePassword bool `gorm:"not null;default:false"`
}
User represents a local or OIDC-authenticated user. Password is only set for local accounts — OIDC users authenticate via the provider and have an empty Password field.