Code sync, security fixes

This commit is contained in:
Andy Dixon
2026-08-11 10:40:10 +01:00
parent 4e00deedf8
commit 53dbc28383
141 changed files with 36194 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
package domain
import "time"
// Role determines what a user may do.
type Role string
const (
RoleAdmin Role = "admin"
RoleEditor Role = "editor"
RoleViewer Role = "viewer"
)
// Valid reports whether r is a recognised role.
func (r Role) Valid() bool {
switch r {
case RoleAdmin, RoleEditor, RoleViewer:
return true
}
return false
}
// CanWrite reports whether the role may create or modify content.
func (r Role) CanWrite() bool { return r == RoleAdmin || r == RoleEditor }
// Workspace is the data boundary. Every user-content record carries a
// workspace ID and every repository query filters on it.
type Workspace struct {
ID string `bson:"_id" json:"id"`
Name string `bson:"name" json:"name"`
Slug string `bson:"slug" json:"slug"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// User is an account, either local or federated. Only the Argon2id encoded
// hash is ever stored for a local account; a federated one has none.
type User struct {
ID string `bson:"_id" json:"id"`
Username string `bson:"username" json:"username"`
// PasswordHash is an encoded Argon2id string including salt and parameters.
// It is empty for a federated account, which can never sign in locally.
PasswordHash string `bson:"password_hash" json:"-"`
// OAuthProvider and OAuthSubject identify a federated account.
//
// The subject is the key, never the email address: `sub` is the only
// identifier a provider promises not to reassign, whereas email addresses
// are recycled — and keying on one would hand a new holder the previous
// holder's documents.
OAuthProvider string `bson:"oauth_provider,omitempty" json:"oauth_provider,omitempty"`
OAuthSubject string `bson:"oauth_subject,omitempty" json:"oauth_subject,omitempty"`
// Email is stored for display and for the optional domain allowlist. It is
// not an identifier and nothing is looked up by it.
Email string `bson:"email,omitempty" json:"email,omitempty"`
DisplayName string `bson:"display_name,omitempty" json:"display_name,omitempty"`
Role Role `bson:"role" json:"role"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
Disabled bool `bson:"disabled" json:"disabled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
LastLoginAt *time.Time `bson:"last_login_at,omitempty" json:"last_login_at,omitempty"`
}
// Session is a server-side browser session. The cookie carries only an opaque
// token; the stored record holds a hash of that token so a database read cannot
// be replayed as a login.
type Session struct {
ID string `bson:"_id" json:"id"`
TokenHash string `bson:"token_hash" json:"-"`
// CSRFToken is a per-session secret compared in constant time on
// state-changing browser requests.
CSRFToken string `bson:"csrf_token" json:"-"`
UserID string `bson:"user_id" json:"user_id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
LastSeenAt time.Time `bson:"last_seen_at" json:"last_seen_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
AbsoluteExpiry time.Time `bson:"absolute_expiry" json:"absolute_expiry"`
UserAgentHash string `bson:"user_agent_hash,omitempty" json:"-"`
Revoked bool `bson:"revoked" json:"revoked"`
}
// Active reports whether the session may still be used at time now.
func (s *Session) Active(now time.Time) bool {
return !s.Revoked && now.Before(s.ExpiresAt) && now.Before(s.AbsoluteExpiry)
}
// Principal is the authenticated caller carried through request contexts.
// Every service call that touches user content takes one.
type Principal struct {
UserID string
Username string
DisplayName string
Role Role
WorkspaceID string
SessionID string
// TokenID and TokenScopes are set when the request authenticated with an
// API token rather than a session. A principal carries one or the other,
// never both, so downstream code can tell an interactive actor from a
// program without inspecting the request.
TokenID string
TokenScopes []TokenScope
}
// ViaToken reports whether this principal came from an API token.
func (p *Principal) ViaToken() bool { return p != nil && p.TokenID != "" }
// Permits reports whether the principal may perform an action needing scope s.
//
// A session-authenticated person is limited only by their role. A token is
// limited by the intersection of its scopes and that role, so a token can never
// do more than the person who created it.
func (p *Principal) Permits(s TokenScope) bool {
if p == nil || !RoleGrants(p.Role, s) {
return false
}
if !p.ViaToken() {
return true
}
for _, got := range p.TokenScopes {
if got == s {
return true
}
}
return false
}
// AuditEvent is an append-only record of a consequential action.
type AuditEvent struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
At time.Time `bson:"at" json:"at"`
Action string `bson:"action" json:"action"`
ActorID string `bson:"actor_id,omitempty" json:"actor_id,omitempty"`
ActorName string `bson:"actor_name,omitempty" json:"actor_name,omitempty"`
TargetKind string `bson:"target_kind,omitempty" json:"target_kind,omitempty"`
TargetID string `bson:"target_id,omitempty" json:"target_id,omitempty"`
RequestID string `bson:"request_id,omitempty" json:"request_id,omitempty"`
Outcome string `bson:"outcome" json:"outcome"`
// Detail holds short, non-sensitive descriptive fields. Never credentials,
// never full document bodies.
Detail map[string]string `bson:"detail,omitempty" json:"detail,omitempty"`
}
// Well-known audit actions.
const (
AuditLogin = "auth.login"
AuditLoginFailed = "auth.login_failed"
AuditLogout = "auth.logout"
AuditUserCreated = "user.created"
AuditItemCreated = "item.created"
AuditItemDeleted = "item.deleted"
AuditItemReprocessed = "item.reprocessed"
AuditRelConfirmed = "relationship.confirmed"
AuditRelRejected = "relationship.rejected"
AuditRelEdited = "relationship.edited"
AuditRelCreated = "relationship.created"
AuditTokenCreated = "api_token.created"
AuditTokenRevoked = "api_token.revoked"
AuditOAuthLogin = "auth.oauth_login"
AuditOAuthProvisioned = "auth.oauth_provisioned"
AuditOAuthFailed = "auth.oauth_failed"
AuditJobRetried = "job.retried"
AuditModelBadOutput = "model.invalid_output"
AuditAnswerGenerated = "answer.generated"
AuditWorkspaceReembed = "workspace.reembed"
)
// ProcessingRun records one pass of the pipeline over an item, retained for
// audit so that a reprocess does not erase what previous versions produced.
type ProcessingRun struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
ItemID string `bson:"item_id" json:"item_id"`
Reason string `bson:"reason" json:"reason"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
Outcome string `bson:"outcome" json:"outcome"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
ParserVersion string `bson:"parser_version,omitempty" json:"parser_version,omitempty"`
EmbeddingModel string `bson:"embedding_model,omitempty" json:"embedding_model,omitempty"`
AnalysisModel string `bson:"analysis_model,omitempty" json:"analysis_model,omitempty"`
ScoreVersion string `bson:"score_version,omitempty" json:"score_version,omitempty"`
Stats map[string]int `bson:"stats,omitempty" json:"stats,omitempty"`
}
// ModelVersion records a model/algorithm version that has been used, so that
// stale derived data can be identified when configuration changes.
type ModelVersion struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
Kind string `bson:"kind" json:"kind"`
Name string `bson:"name" json:"name"`
Dimensions int `bson:"dimensions,omitempty" json:"dimensions,omitempty"`
FirstSeenAt time.Time `bson:"first_seen_at" json:"first_seen_at"`
LastSeenAt time.Time `bson:"last_seen_at" json:"last_seen_at"`
}
// Federated reports whether the account signs in through an identity provider.
func (u *User) Federated() bool { return u != nil && u.OAuthSubject != "" }
// CanLoginLocally reports whether a username and password could ever
// authenticate this account. A federated account has no password hash, so no
// submitted password can match one.
func (u *User) CanLoginLocally() bool {
return u != nil && u.PasswordHash != "" && !u.Federated()
}
// ---------------------------------------------------------------------------
// API tokens
// ---------------------------------------------------------------------------
// TokenScope is a coarse permission carried by an API token.
type TokenScope string
const (
// ScopeIngest permits creating, reprocessing and deleting items.
ScopeIngest TokenScope = "ingest"
// ScopeQuery permits search, answer generation, and reading stored records.
ScopeQuery TokenScope = "query"
// ScopeReview permits acting on relationship and alias review decisions.
ScopeReview TokenScope = "review"
)
// Valid reports whether s is a recognised scope.
func (s TokenScope) Valid() bool {
switch s {
case ScopeIngest, ScopeQuery, ScopeReview:
return true
}
return false
}
// AllTokenScopes lists every scope, for validation and for the interface.
func AllTokenScopes() []TokenScope {
return []TokenScope{ScopeIngest, ScopeQuery, ScopeReview}
}
// RoleGrants reports whether a role is capable of a scope at all. A token can
// never exceed the role of the user who created it, so the effective permission
// is the intersection of the two — which means demoting a person immediately
// narrows every token they hold, without touching the tokens.
func RoleGrants(r Role, s TokenScope) bool {
switch s {
case ScopeQuery:
return r.Valid()
case ScopeIngest, ScopeReview:
return r.CanWrite()
}
return false
}
// APIToken is a non-interactive credential for /api/v1.
//
// Only the hash of the secret is stored, so neither a database read nor a
// backup yields a usable credential.
type APIToken struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
UserID string `bson:"user_id" json:"user_id"`
// Name is the person's own label for the token.
Name string `bson:"name" json:"name"`
// Prefix is the leading, non-secret portion of the token, kept in clear so
// the interface can identify a token without holding it.
Prefix string `bson:"prefix" json:"prefix"`
// TokenHash is the SHA-256 of the whole token. It is never returned.
TokenHash string `bson:"token_hash" json:"-"`
Scopes []TokenScope `bson:"scopes" json:"scopes"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
// RevokedBy records who revoked it, for the audit trail.
RevokedBy string `bson:"revoked_by,omitempty" json:"revoked_by,omitempty"`
}
// Active reports whether the token may authenticate a request at time now.
func (t *APIToken) Active(now time.Time) bool {
if t == nil || t.RevokedAt != nil {
return false
}
return t.ExpiresAt.IsZero() || now.Before(t.ExpiresAt)
}
// HasScope reports whether the token was granted s.
func (t *APIToken) HasScope(s TokenScope) bool {
if t == nil {
return false
}
for _, got := range t.Scopes {
if got == s {
return true
}
}
return false
}