310 lines
12 KiB
Go
310 lines
12 KiB
Go
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
|
|
}
|