Documentation
¶
Index ¶
- Constants
- Variables
- func AddCollectionMembers(db *gorm.DB, id string, members []string, addedBy, addedByID string, ...) error
- func AddGroupMember(db *gorm.DB, groupId, userId, addedByUserId string, isAdmin bool) error
- func ApplyDefaultUserScopes(db *gorm.DB, userID string, granter Creator)
- func AutoMigrateCredentialsForTests(db *gorm.DB) error
- func BackfillNewUserDefaultScopes(db *gorm.DB) error
- func BootstrapAdminAndBackfillOwners(db *gorm.DB) error
- func CallerIsCollectionOwner(collection *Collection, username, userID string) bool
- func CallerIsCollectionOwnerOrAdmin(db *gorm.DB, collection *Collection, username, userID string, groups []string) bool
- func CanManageGroup(db *gorm.DB, group *Group, userID string, isSystemAdmin bool) bool
- func CanSeeGroup(db *gorm.DB, group *Group, userID string, isSystemAdmin bool, ...) bool
- func CleanNamespacePath(ns string) (string, error)
- func ClearAUPAgreement(db *gorm.DB, userID string) error
- func ClearMasterKeyRows(ctx context.Context, db *gorm.DB) error
- func CreateBackup(ctx context.Context) error
- func CreateCounter(key string, value int) error
- func CreateDowntime(downtime *server_structs.Downtime) error
- func CreateOrUpdateCounter(key string, value int) error
- func DecryptMasterKey(encryptedBlob []byte, serverKey jwk.Key) ([]byte, error)
- func DefaultUserScopesFromConfig() ([]token_scopes.TokenScope, error)
- func DeleteCollection(db *gorm.DB, id string, owner, ownerID string, groups []string, isAdmin bool) error
- func DeleteCollectionMetadata(db *gorm.DB, id, user, userID string, groups []string, key string, ...) error
- func DeleteDowntime(uuid string) error
- func DeleteGroup(db *gorm.DB, groupID, requestorUserID string, isAdmin bool) error
- func DeleteMasterKeyRows(ctx context.Context, db *gorm.DB, fingerprints []string) error
- func DeleteUser(db *gorm.DB, userID, requestorUserID string, isAdmin bool) error
- func DeleteUserIdentity(db *gorm.DB, identityID, userID string) error
- func DeriveSubKey(masterKey []byte, purpose string, length int) ([]byte, error)
- func EffectiveScopes(db *gorm.DB, userID string, externalGroupNames []string) ([]token_scopes.TokenScope, error)
- func EncryptMasterKey(masterKey []byte, serverKey jwk.Key) ([]byte, error)
- func ExpandCallerACLGroups(db *gorm.DB, user, userID string, groups []string) []string
- func FilterAuthTemplateEligibleGroups(db *gorm.DB, names []string) []string
- func GetAllDowntimes(source string) ([]server_structs.Downtime, error)
- func GetDowntimeByUUID(uuid string) (*server_structs.Downtime, error)
- func GetGroupCards(db *gorm.DB, ids []string) (map[string]GroupCard, error)
- func GetIncompleteDowntimes(source string) ([]server_structs.Downtime, error)
- func GetServerLocalMetadata() (server_structs.ServerLocalMetadata, error)
- func GetServerLocalMetadataHistory() ([]server_structs.ServerLocalMetadata, error)
- func GetUserCards(db *gorm.DB, ids []string) (map[string]UserCard, error)
- func GrantCollectionAcl(db *gorm.DB, id, user, userID string, groups []string, groupId string, ...) error
- func GrantGroupScope(db *gorm.DB, groupID string, scope token_scopes.TokenScope, granter Creator) error
- func GrantUserScope(db *gorm.DB, userID string, scope token_scopes.TokenScope, granter Creator) error
- func HasEffectiveScope(db *gorm.DB, userID string, externalGroupNames []string, ...) bool
- func HashAUPContent(content string) string
- func InitServerDatabase(serverType server_structs.ServerType) error
- func InsertMockDowntime(d server_structs.Downtime) error
- func IsACLGroupVirtual(name string) bool
- func LaunchPeriodicBackup(ctx context.Context, egrp *errgroup.Group)
- func LeaveGroup(db *gorm.DB, groupID, userID string) error
- func LoadMasterKeyRows(ctx context.Context, db *gorm.DB) (map[string][]byte, error)
- func LoadOrCreateMasterKey(db *gorm.DB) ([]byte, error)
- func RecordAUPAgreement(db *gorm.DB, userID string, version string) error
- func RedeemCollectionOwnershipInviteLink(db *gorm.DB, plaintext string, redeemerUserID string) (string, string, error)
- func RedeemGroupInviteLink(db *gorm.DB, plaintext string, userID string, sub string, issuer string, ...) (string, string, error)
- func RedeemPasswordInviteLink(db *gorm.DB, plaintext, newPassword string) (string, error)
- func RemoveCollectionMembers(db *gorm.DB, id string, members []string, user, userID string, groups []string, ...) error
- func RemoveGroupMember(db *gorm.DB, groupId, userId, removedByUserId string, isAdmin bool) error
- func RenameUser(db *gorm.DB, id, newUsername, localIssuer string) error
- func RestoreFromBackup(dbPath string) (bool, error)
- func RestoreFromSpecificBackup(dbPath, backupPath string, force bool) error
- func RevokeCollectionAcl(db *gorm.DB, id, user, userID string, groups []string, groupId string, ...) error
- func RevokeGroupInviteLink(db *gorm.DB, linkID, requestorUserID string, isSystemAdmin bool) error
- func RevokeGroupScope(db *gorm.DB, groupID string, scope token_scopes.TokenScope) error
- func RevokeUserScope(db *gorm.DB, userID string, scope token_scopes.TokenScope) error
- func SanitizeIdentifier(s string) string
- func SaveMasterKeyRow(ctx context.Context, db *gorm.DB, fingerprint string, encryptedKey []byte) error
- func SetUserPassword(db *gorm.DB, userID, plaintext string) error
- func SetupMockDowntimeDB(t *testing.T)
- func ShutdownDB() error
- func SoftDeleteServerLocalMetadata(id string) error
- func SyncMasterKeyRows(db *gorm.DB, masterKey []byte, currentKeys map[string]jwk.Key) error
- func TeardownMockDowntimeDB(t *testing.T)
- func UpdateCollection(db *gorm.DB, id, user, userID string, groups []string, ...) error
- func UpdateDowntime(uuid string, updatedDowntime *server_structs.Downtime) error
- func UpdateGroup(db *gorm.DB, id string, name, displayName, description *string, ...) error
- func UpdateGroupOwnership(db *gorm.DB, id string, ownerID, adminID *string, adminType *AdminType, ...) error
- func UpdateUser(db *gorm.DB, id string, username, sub, issuer *string) error
- func UpdateUserDisplayName(db *gorm.DB, userID string, displayName string) error
- func UpdateUserLastLogin(db *gorm.DB, userID string) error
- func UpdateUserStatus(db *gorm.DB, userID string, status UserStatus) error
- func UpsertCollectionMetadata(db *gorm.DB, id, user, userID string, groups []string, key, value string, ...) error
- func UpsertServerLocalMetadata(metadata server_structs.ServerRegistration) error
- func ValidateDisplayName(name string) error
- func ValidateIdentifier(name string) error
- func VerifyBackup(backupPath string) error
- type AUPDocument
- func GetAUPDocumentByVersion(db *gorm.DB, version string) (*AUPDocument, error)
- func GetActiveAUPDocument(db *gorm.DB) (*AUPDocument, error)
- func ListAUPDocuments(db *gorm.DB) ([]AUPDocument, error)
- func SaveActiveAUPDocument(db *gorm.DB, content, lastUpdatedLabel string, creator Creator) (*AUPDocument, error)
- type AclRole
- type AdminType
- type AuthMethod
- type BackupInfo
- type BackupMetadata
- type Collection
- func CreateCollection(db *gorm.DB, name, description, owner, ownerID, namespace string, ...) (*Collection, error)
- func CreateCollectionWithMetadata(db *gorm.DB, name, description, owner, ownerID, namespace string, ...) (*Collection, error)
- func CreateShare(db *gorm.DB, req CreateShareReq) (*Collection, error)
- func GetAllCollections(db *gorm.DB) ([]Collection, error)
- func GetCollection(db *gorm.DB, id string, user, userID string, groups []string, isAdmin bool) (*Collection, error)
- func ListCollectionShares(db *gorm.DB, parentID, user, userID string, groups []string, isAdmin bool) ([]Collection, error)
- func ListCollections(db *gorm.DB, user, userID string, groups []string, isAdmin bool) ([]Collection, error)
- type CollectionACL
- type CollectionMember
- type CollectionMetadata
- type Counter
- type CreateShareReq
- type Creator
- type ErrNoMatchingKey
- type Group
- func CreateGroup(db *gorm.DB, name, displayName, description string, creator Creator, ...) (*Group, error)
- func GetGroupWithMembers(db *gorm.DB, groupId string) (*Group, error)
- func GetMemberGroups(db *gorm.DB, userId string) ([]Group, error)
- func ListGroups(db *gorm.DB) ([]Group, error)
- func ListGroupsVisibleToUser(db *gorm.DB, userID string, externalGroupNames []string) ([]Group, error)
- type GroupCard
- type GroupInviteLink
- func CreateCollectionOwnershipInviteLink(db *gorm.DB, collectionID, createdByUsername, createdByUserID string, ...) (*GroupInviteLink, string, error)
- func CreateGroupInviteLink(db *gorm.DB, groupID, createdByUserID string, expiresAt time.Time, ...) (*GroupInviteLink, string, error)
- func CreatePasswordInviteLink(db *gorm.DB, targetUserID, createdByUserID string, expiresAt time.Time, ...) (*GroupInviteLink, string, error)
- func CreateUserOnboardingInviteLink(db *gorm.DB, createdByUserID string, expiresAt time.Time, isSingleUse bool, ...) (*GroupInviteLink, string, error)
- func GetGroupInviteLinkByToken(db *gorm.DB, plaintext string) (*GroupInviteLink, error)
- func ListGroupInviteLinks(db *gorm.DB, groupID string) ([]GroupInviteLink, error)
- func ListPasswordInvitesForUser(db *gorm.DB, userID string) ([]GroupInviteLink, error)
- func LookupInviteLinkByToken(db *gorm.DB, plaintext string) (*GroupInviteLink, error)
- type GroupMember
- type GroupScope
- type InviteKind
- type InviteLink
- type User
- func CreateLocalUser(db *gorm.DB, username, displayName, localIssuer string, creator Creator) (*User, error)
- func CreateUser(db *gorm.DB, username string, sub string, issuer string, creator Creator) (*User, error)
- func GetOrCreateUser(db *gorm.DB, username string, sub string, issuer string, creator Creator) (*User, error)
- func GetUserByID(db *gorm.DB, id string) (*User, error)
- func GetUserByIdentity(db *gorm.DB, sub, issuer string) (*User, error)
- func GetUserByUsername(db *gorm.DB, username string) (*User, error)
- func ListUsers(db *gorm.DB) ([]User, error)
- func LookupOrBootstrapUser(db *gorm.DB, sub, issuer, displayName string, usernameCandidates []string) (*User, error)
- func VerifyUserPassword(db *gorm.DB, username, plaintext, issuer string) (*User, error)
- type UserCard
- type UserIdentity
- type UserScope
- type UserStatus
- type Visibility
Constants ¶
const ( // CreatorSelfEnrolled marks accounts auto-created on the user's // first OIDC sign-in. There is no other user "responsible for" // the account in that case — the user enrolled themselves by // authenticating. CreatorSelfEnrolled = "self-enrolled" // CreatorUnknown is the backfill value for rows that predate the // created_by column. Treat as "we don't know" — not as a security // claim about the account. CreatorUnknown = "unknown" )
Sentinel values for User.CreatedBy when no real creator user ID applies. Per the design contract these are reserved strings, not foreign-key references.
const AllAuthenticatedUsersACLGroup = "@authenticated"
AllAuthenticatedUsersACLGroup is the sentinel value that may appear in CollectionACL.GroupID to grant the row's role to every authenticated caller, regardless of group membership. The string begins with `@`, which `ValidateIdentifier` rejects (identifiers must start with an alphanumeric), so this sentinel can never collide with a real Group.Name. Treat it like a group: callers add it to their effective-groups list when authenticated, and the existing ACL evaluator matches it the same way it would match any other group name. See validateACL / ListCollections / GetUserCollectionScopes for the injection points.
const (
// naclNonceSize is the byte length of a NaCl box nonce.
NaclNonceSize = 24
)
Variables ¶
var ( ErrForbidden = errors.New("forbidden") // ErrReservedGroupPrefix indicates a requested group name collides with the // reserved prefix used for automatically managed personal groups. ErrReservedGroupPrefix = errors.New("reserved group name prefix 'user-'") // ErrInvalidPassword is returned by VerifyLocalUserPassword when the user // exists but has no local password configured or the password doesn't match. ErrInvalidPassword = errors.New("invalid username or password") )
var EmbedOriginMigrations embed.FS
var EmbedRegistryMigrations embed.FS
var EmbedUniversalMigrations embed.FS
var ErrDatabaseExists = errors.New("database already exists at restore target")
ErrDatabaseExists is returned by RestoreFromSpecificBackup when the target database file already exists and force is false.
var ErrInvalidDisplayName = errors.New("invalid display name: must be 1-128 characters with no control characters")
ErrInvalidDisplayName is the analogous error for display names. We allow a much wider character class here (display names are meant for humans, not policy strings) but still bound length and reject control characters.
var ErrInvalidIdentifier = errors.New("invalid identifier: must be 2-64 characters; allowed: A-Z, a-z, 0-9, '.', '_', '@', '-'; must start with a letter or digit; '/' is forbidden")
ErrInvalidIdentifier is returned when a user-supplied name (a username or a group name — the *machine-readable* names, NOT display names) fails identifier validation. Callers — both DB-layer functions and HTTP handlers — should treat this as a bad-request: surface the specific reason to the user, do not fall back silently.
var ErrInvalidNamespacePath = errors.New("invalid namespace path: must be an absolute '/'-rooted path with no whitespace, control characters, or ':'")
ErrInvalidNamespacePath is returned when a federation namespace path (a collection or share namespace — a '/'-rooted object path, NOT a machine identifier) fails validation. Surface it as a bad-request.
var ErrSharingDisabled = errors.New("sharing is not enabled on this collection")
ErrSharingDisabled means the parent collection has not opted into user-driven shares (Collection.EnableSharing == false). Surface this from the share-create handler as a 409 — refusing is correct, but the caller should know it's a deliberate opt-out, not an authorization issue.
var ErrUngrantableScope = errors.New("scope is not user-grantable; only management scopes can be assigned to users or groups")
ErrUngrantableScope is returned by GrantUserScope / GrantGroupScope when the supplied scope is not in token_scopes.UserGrantableScopes. Hard-coding the allow-list at the boundary stops a misconfigured admin tool from inserting a data-plane scope into the management tables.
var ( ScopeToRole map[token_scopes.TokenScope][]AclRole = map[token_scopes.TokenScope][]AclRole{ token_scopes.Collection_Read: {AclRoleRead, AclRoleWrite, AclRoleOwner}, token_scopes.Collection_Modify: {AclRoleWrite, AclRoleOwner}, token_scopes.Collection_Delete: {AclRoleOwner}, } )
var ServerDatabase *gorm.DB
Functions ¶
func AddCollectionMembers ¶
func AddGroupMember ¶
func ApplyDefaultUserScopes ¶
ApplyDefaultUserScopes grants the operator-configured baseline scopes (Server.NewUserDefaultScopes, default web_ui.access) to a freshly-created user. Called from each user-creation path so a new account starts with the same baseline regardless of how it was minted.
Errors are logged but never returned: failing to grant a baseline scope must not void the user record itself, and any operator-side misconfiguration would already have surfaced in startup logs via the backfill's validation pass. The startup backfill also runs as a safety net for any user a transient grant failure missed here.
func AutoMigrateCredentialsForTests ¶
AutoMigrateCredentialsForTests runs GORM AutoMigrate on the credential view of the users table so test setups (which build their schema from struct tags rather than running goose migrations) end up with the password_hash column. The User struct intentionally has no PasswordHash field; this is the only public way for tests outside the database package to get the column without reaching into the migration files.
Production code should not call this — production schema is created by the goose migrations under database/universal_migrations.
func BackfillNewUserDefaultScopes ¶
BackfillNewUserDefaultScopes grants the operator-configured Server.NewUserDefaultScopes to every existing user account, ONCE per server installation. Subsequent runs are no-ops via the counters row keyed by newUserDefaultScopesBackfillKey.
The backfill exists because the auto-grant path triggers only at user-creation time. Pre-existing users (created before the knob existed, or before its current default) wouldn't otherwise pick up the baseline scope; this gives them the same starting posture a new account would receive on the same server.
Subsequent edits to Server.NewUserDefaultScopes do NOT propagate retroactively — by design. The backfill is a one-time fixup, not a continuous reconciliation; "I added a baseline scope after running for a year" must not silently grant that scope to every existing account. Operators who want that behavior should grant the scope to a relevant group instead.
func BootstrapAdminAndBackfillOwners ¶
BootstrapAdminAndBackfillOwners ensures the built-in "admin" user record exists and that every group has a real owner_id. Both are runtime concerns (the admin's primary identity is keyed off Server.ExternalWebUrl, which isn't known at SQL-migration time; existing groups created before owner_id existed need to be assigned to a concrete user). Safe to call on every startup; idempotent.
The function is conservative: if Server.ExternalWebUrl isn't configured yet (e.g. brand-new install before the operator has set the externally-visible URL), it returns nil and skips both steps. They'll be retried the next time it's called.
func CallerIsCollectionOwner ¶
func CallerIsCollectionOwner(collection *Collection, username, userID string) bool
CallerIsCollectionOwner is the strict variant of CallerIsCollectionOwnerOrAdmin: it returns true ONLY when the caller is the actual owner of the collection (matched by User.ID slug or the legacy username). Admin-group members do NOT pass.
Used to gate the dispose-of-the-collection operations — ownership transfer and deletion. Admin-group members can manage members, ACLs, metadata, and descriptive fields, but the design contract reserves "transfer or destroy" for the owner alone (otherwise an admin-group member could lock the actual owner out of their own collection by reassigning ownership to themselves or wiping the row).
Privileged scopes (server.admin / server.collection_admin) are NOT consulted here — the caller is responsible for layering an admin-scope check on top when that bypass is appropriate (it is for the management API, but isn't a property of this helper).
func CallerIsCollectionOwnerOrAdmin ¶
func CallerIsCollectionOwnerOrAdmin(db *gorm.DB, collection *Collection, username, userID string, groups []string) bool
CallerIsCollectionOwnerOrAdmin reports whether the caller's identity (username + User.ID + group memberships) gives them owner-or-admin authority on the collection: matches Collection.Owner (legacy username path), Collection.OwnerID (new User.ID path), or is a member of Collection.AdminID (the admin group). When this returns true the caller skips the ACL check entirely — they have full management authority.
`db` may be nil during in-memory unit tests of the ACL filter; in that case the admin-group lookup is skipped (membership check requires a query) and the function still answers correctly for the owner cases.
func CanManageGroup ¶
CanManageGroup is the exported wrapper around isGroupOwnerOrAdmin: returns true when the user can perform owner/admin-level actions (add/remove members, manage invite links, edit metadata).
func CanSeeGroup ¶
func CanSeeGroup(db *gorm.DB, group *Group, userID string, isSystemAdmin bool, externalGroupNames []string) bool
CanSeeGroup returns true when the user has any visibility into the group: system admin, owner, admin (user or via admin-group membership), member recorded in the DB, or member recorded in an external source (the caller's wlcg.groups claim, sourced from the OIDC IdP or the htpasswd bootstrap path). Used to gate read endpoints (GET /groups/:id, list members) so non-admin callers can see groups they belong to but not the rest of the federation.
externalGroupNames is the slice of group *names* the caller's login cookie carried in. We match against group_name (not ID) because that's what every external source uses — see the contract on the Group struct: Name is the machine-readable handle and is what wlcg.groups carries.
func CleanNamespacePath ¶
CleanNamespacePath validates and canonicalizes a federation namespace path. It is the single choke point for namespaces that later get spliced into token scope strings (e.g. storage.read:/foo, share.access:/id) and compared by path prefix. Two properties it guarantees, each closing a concrete escalation path:
It returns path.Clean'd output, so a prefix check performed on the result and a scope minted from the result can never disagree. Without this, a raw-string prefix check accepts a traversal like "/org/foo/../../secret" (it literally starts with "/org/foo/") while the mint-side path.Clean turns it into "/secret" — granting a storage scope on a prefix entirely outside the parent.
It rejects whitespace, control characters, and ':'. The WLCG scope claim is space-delimited and ':' separates a scope from its path, so a namespace like "/parent/x storage.modify:/etc" would split into two scopes downstream — injecting an authorization the caller never held.
Callers must use the returned, cleaned value for BOTH the authorization prefix check and persistence; validating the raw input but storing or minting from a different form would reopen property (1).
func ClearAUPAgreement ¶
ClearAUPAgreement wipes a user's recorded AUP acceptance so the next /whoami fetch (and therefore the AuthenticatedContent gate) flags them as needing to re-accept. Useful when an admin wants to force a single user back through the workflow without rotating the active AUP version for everyone.
We blank both columns rather than just bumping aup_version because aup_agreed_at is part of the audit trail; preserving a stale timestamp here would suggest the user signed when they did not. Returns gorm.ErrRecordNotFound when the user ID doesn't resolve.
func ClearMasterKeyRows ¶
ClearMasterKeyRows removes all rows from server_master_keys.
func CreateBackup ¶
CreateBackup creates a compressed and encrypted backup of the database. This is the exported entry point for the CLI.
func CreateCounter ¶
func CreateDowntime ¶
func CreateDowntime(downtime *server_structs.Downtime) error
CRUD operations for downtimes table Create a new downtime entry
func CreateOrUpdateCounter ¶
func DecryptMasterKey ¶
DecryptMasterKey decrypts a blob produced by EncryptMasterKey using the corresponding server private key.
func DefaultUserScopesFromConfig ¶
func DefaultUserScopesFromConfig() ([]token_scopes.TokenScope, error)
DefaultUserScopesFromConfig parses Server.NewUserDefaultScopes into a validated TokenScope slice. Each entry must be user-grantable (token_scopes.IsUserGrantable) — operators can't bootstrap a new account with a data-plane (wlcg.*, scitokens.*) or inter-server scope through this knob. Returns ErrUngrantableScope (wrapped) on the first bad entry; ApplyDefaultUserScopes / the startup backfill surface that as a logged warning rather than a hard failure so a misconfigured value doesn't take the whole server out of service.
An empty / unset config returns an empty slice (no auto-grants).
func DeleteCollection ¶
func DeleteDowntime ¶
Delete a downtime entry by UUID (hard delete)
func DeleteGroup ¶
DeleteGroup deletes a group and cleans up any collection ACL entries that reference the group's name (ACLs store group names, not group slugs).
Only the group owner or a system admin may delete the group.
func DeleteMasterKeyRows ¶
DeleteMasterKeyRows removes rows whose fingerprints are in the given list.
func DeleteUser ¶
DeleteUser deletes a user and cleans up any collection ACL entries that reference the user's implicit personal group name ("user-"+username).
If isAdmin is false, only the user themselves may delete their account.
func DeleteUserIdentity ¶
DeleteUserIdentity removes a specific *secondary* identity row. Returns gorm.ErrRecordNotFound if no row matches (identity ID unknown, or it belongs to a different user) — same observable behavior either way, so handlers don't need to distinguish "wrong user" from "doesn't exist" and accidentally leak existence.
This function only operates on the user_identities table; the primary identity carried on the User row is intentionally not removable here. See the user/group design contract.
func DeriveSubKey ¶
DeriveSubKey derives a purpose-specific sub-key from the master key using HKDF-SHA256. The purpose string (HKDF "info") distinguishes different key usages, preventing one derived key from being usable in another context.
func EffectiveScopes ¶
func EffectiveScopes(db *gorm.DB, userID string, externalGroupNames []string) ([]token_scopes.TokenScope, error)
EffectiveScopes returns the union of every scope that applies to the supplied user. Sources, in order:
- user_scopes for userID (direct grants).
- group_scopes for every group the user is a row-member of (transitive via group_members).
- group_scopes for every group whose `name` is in externalGroupNames — i.e. a group asserted by the caller's login cookie (wlcg.groups, sourced from OIDC or htpasswd).
The result is deduplicated and filtered to the user-grantable allow-list, so a stale row referencing a no-longer-grantable scope does not leak through. EffectiveScopes does NOT consult any config (Server.UIAdminUsers, etc.) — the admin-bootstrap path populates user_scopes/group_scopes from those settings at startup, so the runtime evaluation has a single source of truth.
userID may be empty when the caller is unauthenticated; the function returns nil in that case.
func EncryptMasterKey ¶
EncryptMasterKey encrypts the master key using a NaCl box derived from the given server private key. Returns nonce (24 bytes) || ciphertext.
func ExpandCallerACLGroups ¶
ExpandCallerACLGroups returns the effective list of "groups" used when matching collection ACL rows for a single caller. It augments the caller-supplied cookie-asserted list with three additional sources, in this order:
- DB-stored memberships from `group_members` joined to `groups` for the user's own User.ID. This makes the listing / ACL gates work for callers whose cookie carries no wlcg.groups (htpasswd login, or OIDC with `Issuer.GroupSource: none`) but who have been added to a group via the management UI. Without this, a user added to "alpha-writers" wouldn't see "alpha" in their collection listing until a re-login on a properly-configured issuer — which is broken from the user's perspective.
- `user-<username>` — the personal group every user implicitly belongs to; carries per-user ACL grants.
- `@authenticated` — the all-authenticated-users sentinel; only added when the caller has any identity (username or User.ID). A bearer-token call with neither set is treated as anonymous and does NOT inherit the sentinel.
`db` may be nil for in-memory unit tests; in that case only the synthetic `user-` / `@authenticated` entries are added (the DB branch is silently skipped). Errors on the membership query are also tolerated — the listing falls back to the cookie-asserted view rather than failing the whole request.
func FilterAuthTemplateEligibleGroups ¶
FilterAuthTemplateEligibleGroups removes from `names` any group whose DB row has auth_template_eligible == false. Names that have no DB row at all (purely OIDC-asserted, no Pelican-managed Group) pass through unchanged — eligibility is a flag on Pelican-managed rows; we don't deny names this server doesn't even know about.
Used by every authz consumer that treats group names as bearer authority: the issuer's `Issuer.AuthorizationTemplates` matcher (oa4mp) and the `Server.*AdminGroups` config matcher (web_ui.EffectiveScopesForIdentity). Collection-ACL evaluation does NOT call this — collection ACLs are operator-set per row, and the unique-name constraint already prevents a self-created group from impersonating an admin-named one.
Empty input → empty output. DB errors fall back to returning the input unchanged: a transient DB hiccup must not silently strip a user's group memberships and turn them into an unprivileged caller. The downside is a brief window where a freshly-flagged ineligible group still matches templates — acceptable.
func GetAllDowntimes ¶
func GetAllDowntimes(source string) ([]server_structs.Downtime, error)
Retrieve all downtime entries
func GetDowntimeByUUID ¶
func GetDowntimeByUUID(uuid string) (*server_structs.Downtime, error)
Retrieve a downtime entry by UUID
func GetGroupCards ¶
GetGroupCards resolves a list of group IDs to their summaries in a single round-trip.
func GetIncompleteDowntimes ¶
func GetIncompleteDowntimes(source string) ([]server_structs.Downtime, error)
Retrieve all downtime entries where EndTime is later than the current UTC time.
func GetServerLocalMetadata ¶
func GetServerLocalMetadata() (server_structs.ServerLocalMetadata, error)
Retrieve the server local metadata in use - lookup the entry whose UpdatedAt is the most recent
func GetServerLocalMetadataHistory ¶
func GetServerLocalMetadataHistory() ([]server_structs.ServerLocalMetadata, error)
Retrieve server local metadata history from most recent to oldest
func GetUserCards ¶
GetUserCards resolves a list of user IDs to their public-safe UserCard summaries in a single round-trip. Unknown IDs are silently dropped from the returned map.
func GrantCollectionAcl ¶
func GrantGroupScope ¶
func GrantGroupScope(db *gorm.DB, groupID string, scope token_scopes.TokenScope, granter Creator) error
GrantGroupScope adds a scope to a group. Same idempotency contract as GrantUserScope.
func GrantUserScope ¶
func GrantUserScope(db *gorm.DB, userID string, scope token_scopes.TokenScope, granter Creator) error
GrantUserScope adds a scope to a user. Idempotent: granting the same scope twice is a no-op (the existing row is preserved).
func HasEffectiveScope ¶
func HasEffectiveScope(db *gorm.DB, userID string, externalGroupNames []string, scope token_scopes.TokenScope) bool
HasEffectiveScope is a convenience wrapper for "does this user have scope X" — the most common shape of the call. Returns false on any DB error after logging would be added by the caller; we don't have a logger here, so callers that care about distinguishing "definitely no" from "couldn't tell" should use EffectiveScopes directly.
func HashAUPContent ¶
HashAUPContent returns the canonical version string for a piece of AUP content. Same shape as the existing whoami / handleGetAUP version field (16 hex chars of SHA-256), so the IDs are interchangeable across the gate, the user record, and this table.
func InitServerDatabase ¶
func InitServerDatabase(serverType server_structs.ServerType) error
Initialize a centralized server database and run universal and server-type-specific migrations
func InsertMockDowntime ¶
func InsertMockDowntime(d server_structs.Downtime) error
func IsACLGroupVirtual ¶
IsACLGroupVirtual reports whether `name` is a known virtual ACL target — currently only the all-authenticated-users sentinel. Frontends and CLI surfaces consult this to render a friendly label instead of the bare `@authenticated` string, and the ACL grant resolver uses it to skip the "real group must exist" lookup.
func LaunchPeriodicBackup ¶
LaunchPeriodicBackup starts a background goroutine that periodically creates database backups. The goroutine is managed by the provided errgroup and cancellable via the context.
On startup, the function checks for existing backups. If none exist, one is created immediately. Otherwise, the first backup is scheduled based on the age of the most recent backup so that the configured frequency is maintained across restarts.
func LeaveGroup ¶
LeaveGroup removes the calling user's own membership from a group. This is distinct from RemoveGroupMember, which requires owner/admin privileges to remove other members. The group's owner cannot leave (they must transfer ownership first), since an ownerless group has no path back to making changes.
Returns gorm.ErrRecordNotFound if the group does not exist or the user is not a member, and ErrForbidden if the user is the group's owner.
func LoadMasterKeyRows ¶
LoadMasterKeyRows returns all rows from server_master_keys as a map[keyFingerprint] → encryptedBlob.
func LoadOrCreateMasterKey ¶
LoadOrCreateMasterKey loads the master key by decrypting any available row in server_master_keys using the server's private keys. If no rows exist (first start), a fresh 32-byte master key is generated. After loading or creating, the rows are synced to match the current set of server private keys so that key rotation is handled transparently.
func RecordAUPAgreement ¶
RecordAUPAgreement records that a user agreed to a specific version of the AUP.
func RedeemCollectionOwnershipInviteLink ¶
func RedeemCollectionOwnershipInviteLink(db *gorm.DB, plaintext string, redeemerUserID string) (string, string, error)
RedeemCollectionOwnershipInviteLink consumes a plaintext ownership invite and transfers the collection's owner fields to the redeemer. The redeemer must be an authenticated user (we record their User.ID as the new owner); the link is marked redeemed in the same transaction so subsequent redemption attempts fail.
Returns (collectionID, previousOwnerID, error). The collectionID lets the redemption page redirect to the now-owned collection; previousOwnerID is informational (audit log).
func RedeemGroupInviteLink ¶
func RedeemGroupInviteLink(db *gorm.DB, plaintext string, userID string, sub string, issuer string, username string) (string, string, error)
RedeemGroupInviteLink redeems an invite link, adding the user to the group. It validates the link is not expired, not revoked, and (if single-use) not already redeemed. If the user does not exist and sub+issuer are provided, the user is auto-created. If username is empty, a username is derived from the sub. RedeemGroupInviteLink consumes a plaintext invite token, resolves or auto-creates the user from the supplied identity, and adds them to the link's group (if any). Returns the joined group's ID on success — empty string for user-onboarding invites that don't reference a group. RedeemGroupInviteLink returns (joinedGroupID, resolvedUserID, error). joinedGroupID is empty for user-onboarding invites that have no group; resolvedUserID is the user that ended up joined (auto-created or pre-existing). Useful so callers can update audit trails and redirect the caller to the right place after redemption.
func RedeemPasswordInviteLink ¶
RedeemPasswordInviteLink consumes a password-kind invite token and sets the bcrypt hash for the link's TargetUserID. The caller is *not* authenticated — possession of the token IS the credential, by design (this is "click the link to set your password"). The link is single-use by construction (see CreatePasswordInviteLink); a successful redemption marks it as redeemed so the same link cannot rotate the password later.
Returns the affected user's ID on success — useful so the caller can e.g. immediately log the user in via setLoginCookie.
func RemoveCollectionMembers ¶
func RemoveGroupMember ¶
func RenameUser ¶
RenameUser is the supported way to change a user's Username. It enforces the design contract's invariant that for users authenticated against the *internal* issuer (i.e. local password accounts), the primary sub must always equal the username — otherwise password login would silently break after a rename, because login looks up (username, issuer) and compares the bcrypt hash on that row.
For OIDC users (issuer != localIssuer) the sub is the IdP-assigned identifier and is left alone; only the Username changes.
Validation, uniqueness checks, and the actual UPDATE happen in a single transaction so a failed sub update can't leave the row in a half-renamed state.
func RestoreFromBackup ¶
RestoreFromBackup restores the database from the most recent backup file if the primary database file is missing. This is the exported entry point used by InitServerDatabase.
func RestoreFromSpecificBackup ¶
RestoreFromSpecificBackup restores the database from a specific backup file. If force is true, the existing database is backed up then overwritten. Returns an error if the database already exists and force is false.
func RevokeCollectionAcl ¶
func RevokeGroupInviteLink ¶
RevokeGroupInviteLink revokes an invite link. Only the group owner or admin (or a system admin) may revoke invite links.
func RevokeGroupScope ¶
func RevokeGroupScope(db *gorm.DB, groupID string, scope token_scopes.TokenScope) error
RevokeGroupScope removes a scope grant on a group.
func RevokeUserScope ¶
func RevokeUserScope(db *gorm.DB, userID string, scope token_scopes.TokenScope) error
RevokeUserScope removes a scope grant. Returns gorm.ErrRecordNotFound when the row didn't exist so callers can distinguish "noop revoke" from "actually removed".
func SanitizeIdentifier ¶
SanitizeIdentifier coerces a candidate identifier (typically a value pulled from an OIDC claim) into a form that passes ValidateIdentifier, or returns "" if no useful sanitisation exists.
The conservative substitution rules:
- Disallowed characters become '_'.
- Leading non-alphanumerics (which would fail the pattern's anchor) are stripped.
- Repeated dots, which trip the '..' guard, are collapsed to one.
- Result is truncated to 64 chars.
Used by LookupOrBootstrapUser to rescue claims like "bockelman/admin" (which would otherwise be rejected) into "bockelman_admin". Returning empty is allowed — the caller falls back to a synthetic name.
func SaveMasterKeyRow ¶
func SaveMasterKeyRow(ctx context.Context, db *gorm.DB, fingerprint string, encryptedKey []byte) error
SaveMasterKeyRow inserts or replaces an encrypted master key row.
func SetUserPassword ¶
SetUserPassword stores a bcrypt hash of plaintext as the user's local password. Pass an empty plaintext to clear the password (disable local login for that account).
func SetupMockDowntimeDB ¶
Test helper functions for Downtime
func ShutdownDB ¶
func ShutdownDB() error
func SoftDeleteServerLocalMetadata ¶
Mark a server local metadata as deleted without actually removing it from the database
func SyncMasterKeyRows ¶
SyncMasterKeyRows ensures server_master_keys has exactly one row per current server private key, each containing the master key encrypted for that key. Rows for keys no longer present are removed.
All changes are made in a single database transaction so the table is never left in an inconsistent state. As a safety measure, if a sync would delete every existing row (implying all server keys were replaced at once), the deletion is skipped and an error is returned — the admin may still be able to recover a missing key file.
func TeardownMockDowntimeDB ¶
func UpdateCollection ¶
func UpdateCollection(db *gorm.DB, id, user, userID string, groups []string, name, description *string, visibility *Visibility, ownerID, adminID *string, enableSharing *bool, isAdmin bool) error
UpdateCollection mutates the high-level fields of a collection. Owner-managed fields (OwnerID, AdminID) live on this same call so the edit-form can patch everything in one round-trip; transferring ownership and (re)assigning the admin group are restricted to callers who pass the existing-owner-or-admin gate (so the current owner can hand the collection to someone else, but a writer can't elevate themselves).
func UpdateDowntime ¶
func UpdateDowntime(uuid string, updatedDowntime *server_structs.Downtime) error
Update an existing downtime entry by UUID
func UpdateGroup ¶
func UpdateGroup(db *gorm.DB, id string, name, displayName, description *string, authTemplateEligible *bool, requestorUserID string, isAdmin, isUserAdminCaller bool) error
UpdateGroup applies updates to a group's mutable fields. Authorization is split per the user/group design contract:
- Name (the machine-readable identifier used in policy strings) may be changed ONLY by a system administrator. Owners and group-admins cannot rename a group, because that would let them rewrite its identity in any policy that references it.
- DisplayName and Description are owner-editable.
`isAdmin` here is the *system admin* flag (the caller passed in from CheckAdmin); group-admin privileges flow through isGroupOwnerOrAdmin.
`isUserAdminCaller` is the user-admin scope-bearer flag. The authTemplateEligible field can be flipped only by an admin or user-admin (a non-admin owner of the group cannot quietly grant their own group authz-template authority).
func UpdateGroupOwnership ¶
func UpdateGroupOwnership(db *gorm.DB, id string, ownerID, adminID *string, adminType *AdminType, requestorUserID string, isSystemAdmin bool) error
UpdateGroupOwnership updates the owner and/or admin settings of a group. Only the group owner (or system admin) may change these settings.
func UpdateUserDisplayName ¶
UpdateUserDisplayName updates the display name of a user.
func UpdateUserLastLogin ¶
UpdateUserLastLogin updates the last login timestamp of a user.
func UpdateUserStatus ¶
func UpdateUserStatus(db *gorm.DB, userID string, status UserStatus) error
UpdateUserStatus updates the status (active/inactive) of a user.
func UpsertServerLocalMetadata ¶
func UpsertServerLocalMetadata(metadata server_structs.ServerRegistration) error
Create or update a record to sync local server metadata with the Registry Server id is an unique 7 characters string randomly generated by the server itself during initial registration, consisting of [0-9a-z], e.g. 18f1jk5 Server name is a human-friendly name set by the admin via SiteName field in webUI or Xrootd.Sitename in local config during initial registration, e.g. "UW_OSDF_CACHE" 1) If no such row exists, it inserts a new one. 2) If a row with that server ID exists, it updates the existing entry.
func ValidateDisplayName ¶
ValidateDisplayName returns nil for an acceptable display name. Empty is fine (the field is optional). Length is the only real constraint; otherwise we accept any printable Unicode so users can spell their names correctly.
func ValidateIdentifier ¶
ValidateIdentifier returns nil if name is a well-formed user/group machine identifier per the design contract on the User/Group structs, or ErrInvalidIdentifier otherwise. Apply at every point a name enters the system: HTTP create/rename handlers, OIDC bootstrap candidate selection, CLI flags. Display names go through their own (laxer) validator.
func VerifyBackup ¶
VerifyBackup checks that a backup file can be successfully decrypted and decompressed without writing any data. Returns nil on success.
Types ¶
type AUPDocument ¶
type AUPDocument struct {
ID string `gorm:"primaryKey" json:"id"`
Version string `gorm:"not null;unique" json:"version"`
Content string `gorm:"not null" json:"content"`
CreatedBy string `gorm:"not null;default:'unknown'" json:"createdBy"`
AuthMethod AuthMethod `gorm:"not null;default:''" json:"authMethod"`
AuthMethodID string `gorm:"not null;default:''" json:"authMethodId,omitempty"`
LastUpdatedLabel string `gorm:"not null;default:''" json:"lastUpdated,omitempty"`
IsActive bool `gorm:"not null;default:false" json:"isActive"`
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
}
AUPDocument mirrors a row in the aup_documents table.
func GetAUPDocumentByVersion ¶
func GetAUPDocumentByVersion(db *gorm.DB, version string) (*AUPDocument, error)
GetAUPDocumentByVersion looks up a specific historical version. Used by versioned AUP URLs (`GET /aup/:version`) and by audit tooling that needs the exact text a given user signed.
func GetActiveAUPDocument ¶
func GetActiveAUPDocument(db *gorm.DB) (*AUPDocument, error)
GetActiveAUPDocument returns the active AUP row, if one exists. Returns gorm.ErrRecordNotFound when no operator-edited copy has ever been saved (the runtime then falls through to the configured file or the embedded default).
func ListAUPDocuments ¶
func ListAUPDocuments(db *gorm.DB) ([]AUPDocument, error)
ListAUPDocuments returns every persisted AUP version, newest first. Powers the admin "AUP history" view; the active version is whichever row has is_active = true.
func SaveActiveAUPDocument ¶
func SaveActiveAUPDocument(db *gorm.DB, content, lastUpdatedLabel string, creator Creator) (*AUPDocument, error)
SaveActiveAUPDocument persists a new AUP version and atomically flips it to active, deactivating whichever row was active before.
Behavior:
- The version hash is computed from the supplied content; callers do not pass it.
- If the same content (same hash) is already in the table, the existing row is reused — content stays unchanged but is_active flips to that row, and its CreatedBy / lastUpdatedLabel audit fields are refreshed to the new edit.
- The single-row "active" invariant is enforced by an UPDATE inside the transaction, not by any caller-provided ordering.
- lastUpdatedLabel is the human-readable date string the operator wants in the AUP footer ("This text was last updated on …"). Empty is fine; the UI falls back to the row's CreatedAt.
Returns the row that ended up active (whether new or reused).
type AclRole ¶
type AclRole string
func EffectiveCollectionRole ¶
func EffectiveCollectionRole(db *gorm.DB, coll *Collection, userID, username string) AclRole
EffectiveCollectionRole returns the highest ACL role the named user holds on the supplied collection, or "" when they hold none. Walks all the same paths CallerIsCollectionOwnerOrAdmin does (direct ownership, admin-group via DB membership, ACL via DB membership and personal `user-<name>` group, the all-authenticated-users sentinel) but does NOT consider cookie- asserted groups — the caller is identified by their stable User row, not their current session.
Used by the share-token-mint intersection: the data plane mints `share.access:/$shareID` plus storage scopes clamped to the share owner's current parent role. No session is available for that owner at mint time, so DB-membership is the only authoritative signal we can consult.
func MinRole ¶
MinRole returns whichever of the two roles is *lower*. Used by the share-token-mint intersection: a recipient's effective role on a share must be clamped by the share owner's CURRENT role on the parent collection — pick the weaker of the two so revocation propagates (the share owner losing write on the parent must not leave the recipient with write on the share).
type AuthMethod ¶
type AuthMethod string
AuthMethod records how the *creator* of a record was authenticated at the moment they created it. Useful for audit trails and incident response: "who created this record, and were they sitting at the web UI or driving it from a script?" Recorded on invite-links, Users, and Groups (and any future record we want to audit similarly).
const ( AuthMethodWebCookie AuthMethod = "web-cookie" AuthMethodAPIToken AuthMethod = "api-token" AuthMethodBearerJWT AuthMethod = "bearer-jwt" )
type BackupInfo ¶
type BackupInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Timestamp time.Time `json:"timestamp"`
Metadata *BackupMetadata `json:"metadata,omitempty"`
}
BackupInfo holds metadata about a backup file, suitable for display in CLI listings.
func ListBackups ¶
func ListBackups() ([]BackupInfo, error)
ListBackups returns metadata about all available backups in the configured backup directory, sorted newest-first.
type BackupMetadata ¶
type BackupMetadata struct {
// FormatVersion is the backup format version (currently "1").
FormatVersion string `json:"format_version"`
// Timestamp is the RFC3339 UTC time the backup was created.
Timestamp string `json:"timestamp"`
// Hostname is the hostname of the machine that created the backup.
Hostname string `json:"hostname,omitempty"`
// Username is the OS user that created the backup.
Username string `json:"username,omitempty"`
// PelicanVersion is the version of Pelican that created the backup.
PelicanVersion string `json:"pelican_version"`
// ServerURL is the external web URL of the server, if configured.
ServerURL string `json:"server_url,omitempty"`
// DatabasePath is the path to the database that was backed up.
DatabasePath string `json:"database_path,omitempty"`
// GOOS is the operating system (e.g., "linux", "darwin").
GOOS string `json:"goos"`
// GOARCH is the architecture (e.g., "amd64", "arm64").
GOARCH string `json:"goarch"`
}
BackupMetadata contains human-readable information about a backup file. These fields are stored as PEM headers in the first block of the backup file and are visible even without decryption keys.
func ReadBackupMetadata ¶
func ReadBackupMetadata(backupPath string) (*BackupMetadata, error)
ReadBackupMetadata reads the metadata from a backup file at the given path. It returns nil (without error) for older backup files that lack a metadata block.
type Collection ¶
type Collection struct {
ID string `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null;uniqueIndex:idx_owner_name" json:"name"`
Description string `json:"description"`
Owner string `gorm:"not null;uniqueIndex:idx_owner_name" json:"owner"`
OwnerID string `gorm:"not null;default:''" json:"ownerId"`
AdminID string `gorm:"not null;default:''" json:"adminId"`
Namespace string `gorm:"not null" json:"namespace"`
Visibility Visibility `gorm:"not null;default:private" json:"visibility"`
// EnableSharing is the operator-set opt-in that lets read-access
// holders mint a "share" — a child collection that delegates a
// subset of this collection's access. Defaults false; flipped only
// by the collection owner / admin-group / collection_admin via
// PATCH. The share self-service endpoint refuses to create a child
// collection when the parent has EnableSharing == false.
EnableSharing bool `gorm:"not null;default:false" json:"enableSharing"`
// ParentCollectionID, when non-empty, marks this row as a *share*
// of the named collection. Per the design (see
// docs/collections-design.md), shares delegate a subset of the
// parent's access to whoever the share is handed to; access-token
// minting clamps the share's effective scopes by the share owner's
// CURRENT access to the parent, so revocation propagates. Set only
// by the share self-service endpoint; immutable thereafter.
// The partial index on this column is created by the migration
// (universal_migrations/20260502165710_collection_parent_id.sql);
// not declaring it on the struct keeps GORM AutoMigrate from
// trying to recreate it as a non-partial index in tests that
// rely on AutoMigrate alone.
ParentCollectionID string `gorm:"not null;default:''" json:"parentCollectionId,omitempty"`
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`
Members []CollectionMember `gorm:"foreignKey:CollectionID" json:"members"`
ACLs []CollectionACL `gorm:"foreignKey:CollectionID" json:"acls"`
Metadata []CollectionMetadata `gorm:"foreignKey:CollectionID" json:"metadata"`
}
Collection — origin-local record of a curated namespace. Ownership model (per the user/group-design rewrite):
- Owner / OwnerID — exactly one user owns the collection. Owner is the legacy username field (kept for audit + back-compat uniqueness on `(owner, name)`); OwnerID is the immutable User.ID slug used for authorization. Authorization checks should compare OwnerID against the caller's User.ID.
- AdminID — the Group.ID of an OPTIONAL admin group whose members can manage the collection day-to-day: edit metadata, manage read/write ACLs, manage members, and reassign the admin group itself. They CANNOT transfer ownership or delete the collection — those stay owner-exclusive so an admin-group member can't seize or destroy the row out from under the rightful owner. Empty when no admin group is configured.
- ACLs — read/write groups attached via CollectionACL rows. The deprecated AclRoleOwner role is tolerated on legacy rows but no longer mints; ownership/admin authority comes from the row's Owner/AdminID fields.
Visibility=public collections are readable by anyone; private ones require Owner / admin-group / ACL membership / collection_admin scope.
func CreateCollection ¶
func CreateCollection(db *gorm.DB, name, description, owner, ownerID, namespace string, visibility Visibility) (*Collection, error)
CreateCollection persists a new collection row owned by `owner` (username, audit field) / `ownerID` (User.ID slug, the authorization handle). Per the ownership-model rewrite the row's own Owner/OwnerID/AdminID fields encode authority — the function no longer auto-mints a `user-<owner>` AclRoleOwner ACL row, so existing ACL listings are not polluted with an owner pseudo-grant.
`ownerID` may be empty for legacy/test paths that don't have a User record handy; in that case the collection has an empty OwnerID and ownership-checks fall through to admin-group / ACL / admin-scope paths. New code SHOULD always supply a real User.ID.
func CreateCollectionWithMetadata ¶
func CreateCollectionWithMetadata(db *gorm.DB, name, description, owner, ownerID, namespace string, visibility Visibility, enableSharing bool, metadata map[string]string) (*Collection, error)
CreateCollectionWithMetadata is the create path used by the HTTP handlers — accepts the same `ownerID` posture as CreateCollection plus an optional metadata map persisted in the same transaction. `enableSharing` opts the collection in to user-driven shares; the flag can also be flipped after creation via UpdateCollection.
func CreateShare ¶
func CreateShare(db *gorm.DB, req CreateShareReq) (*Collection, error)
CreateShare persists a new share — a child Collection whose `parent_collection_id` is the parent's ID. Authorization is the caller's responsibility; this helper enforces only the data-model invariants:
- The parent must exist.
- The parent must have EnableSharing == true (else ErrSharingDisabled, distinct from ErrForbidden so the handler can surface a clearer message than "not found").
- The supplied namespace must be a prefix-or-equal of the parent's namespace — you can't delegate access you don't have.
The handler is expected to enforce: caller has Collection_Read on the parent, AND the configured Origin storage backend is not multi-user (impersonation isn't supported there per the design).
The new share starts with no ACLs, no admin group, and EnableSharing = false. The share owner is free to add ACLs after the fact (they hold owner authority on the row).
func GetAllCollections ¶
func GetAllCollections(db *gorm.DB) ([]Collection, error)
func GetCollection ¶
func ListCollectionShares ¶
func ListCollectionShares(db *gorm.DB, parentID, user, userID string, groups []string, isAdmin bool) ([]Collection, error)
ListCollectionShares returns every collection that has its `parent_collection_id` set to the supplied parent ID — i.e. every share of that parent. The visibility filter mirrors ListCollections: an admin-bypass returns all shares, otherwise shares are filtered to the same {public, owned, admin-group, ACL} union as plain collections. The caller is expected to pass the already-fetched parent's authorisation gate elsewhere; this helper only filters its own results, so a private share inside a parent the caller can read still hides if the share's ACL doesn't admit them.
func ListCollections ¶
func ListCollections(db *gorm.DB, user, userID string, groups []string, isAdmin bool) ([]Collection, error)
ListCollections returns every collection the caller can see. The visibility set is the union of:
- Public collections (visibility=public).
- Collections the caller owns — by OwnerID (canonical) or by legacy Owner username. Without this branch a freshly-transferred owner's listing comes up empty: the row carries no read ACL for them, and the auto-owner ACL is gone per the ownership-model rewrite, so OwnerID is the *only* link between the user and the row.
- Collections whose admin-group the caller is a member of, where "member" means: a row in group_members (DB-driven membership) OR a name in the caller's `groups` slice that resolves to the admin group (cookie/IdP-asserted membership). Mirrors the same two-path admin-group check that CallerIsCollectionOwnerOrAdmin uses on the management side.
- Collections with a read-eligible ACL for one of the caller's groups (the existing path).
Admins (server.admin / server.collection_admin) bypass and get global visibility — the management endpoints already do this and the list should match so admin-as-investigator works.
type CollectionACL ¶
type CollectionACL struct {
CollectionID string `gorm:"primaryKey" json:"collectionId"`
GroupID string `gorm:"primaryKey" json:"groupId"`
Role AclRole `gorm:"primaryKey;not null" json:"role"`
GrantedBy string `gorm:"not null" json:"createdBy"`
GrantedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt"`
}
func GetCollectionAcls ¶
type CollectionMember ¶
type CollectionMetadata ¶
type CollectionMetadata struct {
CollectionID string `gorm:"primaryKey" json:"collectionId"`
Key string `gorm:"primaryKey;not null" json:"key"`
Value string `gorm:"not null" json:"value"`
}
func GetCollectionMetadata ¶
type CreateShareReq ¶
type CreateShareReq struct {
// Owner identity — the share is owned by the caller minting it,
// not by the parent collection's owner. Per the design, access
// tokens for the share's prefixes are clamped to whatever the
// share owner currently has on the parent — that intersection
// happens at token-mint time (see oa4mp), not here.
}
CreateShareReq is the input for CreateShare. Mirrors CreateCollectionWithMetadata's positional arguments but bundles share-specific fields (the parent + the share-owner identity) and dis-allows the operator-only knobs (no admin group, no enable-sharing flag, no metadata) on the create path. A share owner can set those later via the regular PATCH surface if they hold the owner gate on the share itself.
type Creator ¶
type Creator struct {
UserID string
AuthMethod AuthMethod
AuthMethodID string
}
Creator bundles the audit fields recorded at every record-creation site. Instead of pushing three positional parameters through every signature, callers construct this once (typically in the HTTP handler via captureAuthMethod) and hand it to the DB layer. CreatorSelf and CreatorUnknownContext are convenience constructors for the common no-attributable-user cases.
func CreatorSelf ¶
func CreatorSelf() Creator
CreatorSelf returns a Creator marking a record as self-enrolled — the user authenticated themselves into existence (OIDC first login).
type ErrNoMatchingKey ¶
type ErrNoMatchingKey struct {
RequiredKeyIDs []string
}
ErrNoMatchingKey is returned when a backup cannot be decrypted because none of the currently-available issuer keys match the keys used to encrypt the backup. The RequiredKeyIDs field lists the key IDs that the backup was encrypted with.
func (*ErrNoMatchingKey) Error ¶
func (e *ErrNoMatchingKey) Error() string
type Group ¶
type Group struct {
ID string `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null;unique" json:"name"`
DisplayName string `gorm:"not null;default:''" json:"displayName"`
Description string `json:"description"`
CreatedBy string `gorm:"not null" json:"createdBy"`
CreatorAuthMethod AuthMethod `gorm:"not null;default:''" json:"creatorAuthMethod"`
CreatorAuthMethodID string `gorm:"not null;default:''" json:"creatorAuthMethodId,omitempty"`
OwnerID string `gorm:"not null;default:''" json:"ownerId"`
AdminID string `gorm:"not null;default:''" json:"adminId"`
AdminType AdminType `gorm:"not null;default:''" json:"adminType"`
// AuthTemplateEligible gates whether this group is allowed to
// match against Issuer.AuthorizationTemplates and the
// Server.*AdminGroups config lists at runtime. Group creation is
// open to any authenticated user so they can mint groups for their
// own collection ACLs / shares; the bit prevents a self-named
// group from also gaining authz-template authority. Only an
// admin / user-admin can set or flip the bit. Pre-existing rows
// (before the open-creation rollout) are migrated to true since
// they were minted by an admin and operators expect them to keep
// matching templates.
// gorm:"not null" without a `default:` tag — the SQL default (TRUE
// for backfill of pre-existing rows) lives in the migration. Adding
// `default:true` here would make GORM substitute the default on
// every insert with a zero Go value, defeating the create-time
// non-admin clamp ("AuthTemplateEligible: false" would round-trip
// as true).
AuthTemplateEligible bool `gorm:"not null" json:"authTemplateEligible"`
// CreatedForCollectionID marks groups minted alongside a specific
// collection during the onboarding flow. The redemption path of a
// collection-ownership invite cascades the transfer to every group
// where this field equals the collection being transferred AND the
// group's current owner still matches the collection's previous
// owner — that "AND owner unchanged" guard avoids yanking a group
// out from under a downstream operator who already re-homed it.
CreatedForCollectionID string `gorm:"not null;default:''" json:"createdForCollectionId,omitempty"`
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`
Members []GroupMember `gorm:"foreignKey:GroupID" json:"members"`
}
Group mirrors the User contract for the four-concept model:
- Name is the *machine-readable* handle: admin-controlled, used in policy strings (admin-group lists, ACL grants, configuration).
- DisplayName is a *human label*: owner-editable, used in the UI.
- ID is an opaque internal primary key.
See ValidateIdentifier for the character class enforced on Name. DisplayName has the laxer ValidateDisplayName ruleset.
func CreateGroup ¶
func CreateGroup(db *gorm.DB, name, displayName, description string, creator Creator, createdForCollectionID string, authTemplateEligible bool) (*Group, error)
CreateGroup persists a new group. The `creator` argument records who minted the group and how they were authenticated; CreatedBy is taken from creator.UserID (so it can be set even if the creator is not also the owner — though by default the creator becomes the owner). For API-driven creation pass the API token's audit info via captureAuthMethod.
`createdForCollectionID` ties the group to a specific collection's onboarding pass. Empty for the standalone group-create path; set by the collection-onboarding flow so a later ownership transfer of that collection can cascade to its onboarded groups (see RedeemCollectionOwnershipInviteLink).
`authTemplateEligible` controls whether this group can match Issuer.AuthorizationTemplates and Server.*AdminGroups at runtime. The handler is responsible for refusing to set it true for a non-admin caller; this layer just persists what it's told.
func ListGroupsVisibleToUser ¶
func ListGroupsVisibleToUser(db *gorm.DB, userID string, externalGroupNames []string) ([]Group, error)
ListGroupsVisibleToUser returns every group the user can see: groups they own, groups where they are AdminID (user or via admin-group membership), groups they are a row-member of, and groups whose *name* matches one of the externalGroupNames the caller asserted via their login cookie (wlcg.groups, populated from the OIDC IdP or the htpasswd bootstrap path). System admins should call ListGroups directly to see every group in the federation.
External-source membership matters because the membership of a user in a group is not always recorded in the DB — an OIDC IdP can assert "this user belongs to group X" without us ever writing a group_members row. Filtering against the externalGroupNames slice (rather than just echoing it) drops any asserted name that doesn't correspond to a real group in the database, so we never pretend a non-existent group exists in API responses.
The query unions five sources via SQL OR rather than running them separately; the helper is intentionally a single round-trip.
type GroupCard ¶
GroupCard is the analogous summary for a group (e.g. when a group itself is the administrator of another group).
type GroupInviteLink ¶
type GroupInviteLink struct {
ID string `gorm:"primaryKey" json:"id"`
Kind InviteKind `gorm:"not null;default:'group';index:idx_invite_links_kind" json:"kind"`
// GroupID is set when Kind == InviteKindGroup. Empty otherwise.
GroupID string `gorm:"not null;default:''" json:"groupId"`
// TargetUserID is set when Kind == InviteKindPassword. Empty otherwise.
// (For group invites the user is whoever redeems; for password invites
// the user is fixed at link creation time and the redeemer must not be
// allowed to set a different account's password.)
TargetUserID string `gorm:"not null;default:'';index:idx_invite_links_target_user" json:"targetUserId"`
// CollectionID is set when Kind == InviteKindCollectionOwnership;
// names the collection whose ownership transfers to the redeemer.
// Empty for every other kind.
CollectionID string `gorm:"not null;default:''" json:"collectionId"`
HashedToken string `gorm:"column:invite_token;not null;unique" json:"-"`
// TokenPrefix is the first few characters of the *plaintext* token,
// captured at mint time. It is NOT a credential — too narrow to brute
// force into the bcrypt hash — but is enough to label, sort, and
// disambiguate live invites in admin UIs and CLI listings.
TokenPrefix string `gorm:"column:token_prefix;not null;default:''" json:"tokenPrefix"`
CreatedBy string `gorm:"not null" json:"createdBy"`
// AuthMethod / AuthMethodID describe how CreatedBy was authenticated
// when this link was minted (web cookie vs API token id, ...). See
// AuthMethod constants above.
AuthMethod AuthMethod `gorm:"not null;default:''" json:"authMethod"`
AuthMethodID string `gorm:"not null;default:''" json:"authMethodId,omitempty"`
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`
ExpiresAt time.Time `gorm:"not null" json:"expiresAt"`
IsSingleUse bool `gorm:"not null;default:false" json:"isSingleUse"`
RedeemedBy string `gorm:"not null;default:''" json:"redeemedBy"`
RedeemedAt *time.Time `json:"redeemedAt"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
}
GroupInviteLink — historical name; this row now backs *every* kind of invite link, not just group-join. See InviteKind.
The unified schema was chosen over per-kind tables because every kind shares the same ~12 columns (token + lifecycle + audit) and only kind-specific link target differs (GroupID vs TargetUserID).
Type alias `InviteLink` is the preferred name for new code.
func CreateCollectionOwnershipInviteLink ¶
func CreateCollectionOwnershipInviteLink(db *gorm.DB, collectionID, createdByUsername, createdByUserID string, callerGroups []string, expiresAt time.Time, isCollectionAdmin bool, authMethod AuthMethod, authMethodID string) (*GroupInviteLink, string, error)
CreateCollectionOwnershipInviteLink mints a single-use invite that, when redeemed by a logged-in user, transfers Collection.OwnerID (and the legacy Collection.Owner username) from the current owner to the redeemer. The previous owner stays referenced via CreatedBy on the invite + audit fields on the collection row, but loses ownership the moment the link is redeemed.
Authorization: the caller must already be able to mutate the collection — owner, admin-group member, or holder of server.collection_admin / server.admin (the same gate as `PATCH /:id` with ownerId / adminId). We derive the result of that gate from `isCollectionAdmin` (the scope-bypass) plus the owner / admin-group check inside the existing helper, mirroring how UpdateCollection re-gates ownership transfers.
Single-use is forced — ownership transfer is by definition a one-shot operation; a multi-use ownership invite would be a security footgun.
func CreateGroupInviteLink ¶
func CreateGroupInviteLink(db *gorm.DB, groupID, createdByUserID string, expiresAt time.Time, isSingleUse bool, isSystemAdmin bool, authMethod AuthMethod, authMethodID string) (*GroupInviteLink, string, error)
CreateGroupInviteLink creates a new invite link for a group. Only the group owner or admin (or a system admin) may create invite links. Returns (inviteLink, plaintextToken, error). The plaintext token is returned only once and not stored.
func CreatePasswordInviteLink ¶
func CreatePasswordInviteLink(db *gorm.DB, targetUserID, createdByUserID string, expiresAt time.Time, authMethod AuthMethod, authMethodID string) (*GroupInviteLink, string, error)
CreatePasswordInviteLink mints a single-use, expiring link that lets the holder set a password for `targetUserID` without ever showing it to the admin. The admin remains responsible for delivering the link out-of-band (email, hand it over in person, ...) — the system does not send it for them.
Authorization is the caller's job; this function trusts that whoever calls it has already established the right to act on `targetUserID` (typically a system admin or user admin).
func CreateUserOnboardingInviteLink ¶
func CreateUserOnboardingInviteLink(db *gorm.DB, createdByUserID string, expiresAt time.Time, isSingleUse bool, authMethod AuthMethod, authMethodID string) (*GroupInviteLink, string, error)
CreateUserOnboardingInviteLink creates an invite link that onboards users without adding them to a group. Only system admins or user administrators can create these. Returns (inviteLink, plaintextToken, error).
func GetGroupInviteLinkByToken ¶
func GetGroupInviteLinkByToken(db *gorm.DB, plaintext string) (*GroupInviteLink, error)
GetGroupInviteLinkByToken looks up an invite link by scanning all non-revoked, non-expired links and comparing the bcrypt hash. Returns nil if not found.
func ListGroupInviteLinks ¶
func ListGroupInviteLinks(db *gorm.DB, groupID string) ([]GroupInviteLink, error)
ListGroupInviteLinks returns all invite links for a given group.
func ListPasswordInvitesForUser ¶
func ListPasswordInvitesForUser(db *gorm.DB, userID string) ([]GroupInviteLink, error)
ListPasswordInvitesForUser returns all password-set invites that target the given user (used for an admin UI to see, e.g., "this user has 2 outstanding setup links and 1 has already been used"). Includes already-redeemed and revoked links so the audit trail is visible.
func LookupInviteLinkByToken ¶
func LookupInviteLinkByToken(db *gorm.DB, plaintext string) (*GroupInviteLink, error)
LookupInviteLinkByToken returns the invite-link metadata for an opaque token, after verifying it is live (not revoked, not expired, and — for single-use links — not already redeemed). Used to back the pre-redemption "what kind of invite is this?" probe so the UI can render the right form (password entry vs. group-join confirmation).
The HashedToken is intentionally elided from the returned record, but otherwise this is the full row, so callers should treat it as non-public information (the token-bearer at least already had to know the token, but cards-with-token aren't free).
type GroupMember ¶
type GroupMember struct {
GroupID string `gorm:"primaryKey" json:"groupId"`
UserID string `gorm:"primaryKey" json:"userId"`
User User `gorm:"foreignKey:UserID" json:"user"`
AddedBy string `gorm:"not null" json:"createdBy"`
AddedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
}
type GroupScope ¶
type GroupScope struct {
GroupID string `gorm:"primaryKey" json:"groupId"`
Scope token_scopes.TokenScope `gorm:"primaryKey;column:scope" json:"scope"`
GrantedBy string `gorm:"not null;default:'unknown'" json:"grantedBy"`
AuthMethod AuthMethod `gorm:"not null;default:''" json:"authMethod"`
AuthMethodID string `gorm:"not null;default:''" json:"authMethodId,omitempty"`
GrantedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"grantedAt"`
}
GroupScope is one row in the group_scopes table — a scope granted to all members of a group.
func ListGroupScopes ¶
func ListGroupScopes(db *gorm.DB, groupID string) ([]GroupScope, error)
ListGroupScopes returns all scope grants for a single group.
type InviteKind ¶
type InviteKind string
InviteKind discriminates what an invite link grants when redeemed.
- InviteKindGroup: redeem-time, the *caller's* user is added to GroupID. Caller must be authenticated (we need a user to add to the group).
- InviteKindPassword: redeem-time, the link sets the password for TargetUserID. Caller need NOT be authenticated — possession of the token IS the credential, by design (this is the "click the link in the email to set your password" pattern). Admins use this to onboard accounts without ever learning the user's password.
- InviteKindCollectionOwnership: redeem-time, the *caller's* user becomes the owner of CollectionID. The previous owner stays a row-level audit reference (Collection.CreatedBy / created_by audit), but Collection.OwnerID and Collection.Owner are overwritten with the redeemer's identity. Caller must be authenticated — we need a real user to transfer ownership to. Forced single-use: ownership transfer is by definition a one-shot operation.
const ( InviteKindGroup InviteKind = "group" InviteKindPassword InviteKind = "password" InviteKindCollectionOwnership InviteKind = "collection_ownership" )
type InviteLink ¶
type InviteLink = GroupInviteLink
InviteLink is the preferred name for the row above. Use it in new code; older call sites still reference GroupInviteLink for backwards compat.
type User ¶
type User struct {
ID string `gorm:"primaryKey" json:"id"`
Username string `gorm:"not null;uniqueIndex:idx_user_issuer" json:"username"`
Sub string `gorm:"not null;uniqueIndex:idx_user_sub_issuer" json:"sub"`
Issuer string `gorm:"not null;uniqueIndex:idx_user_issuer;uniqueIndex:idx_user_sub_issuer" json:"issuer"`
Status UserStatus `gorm:"not null;default:active" json:"status"`
LastLoginAt *time.Time `json:"lastLoginAt"`
DisplayName string `gorm:"not null;default:''" json:"displayName"`
AUPVersion string `gorm:"not null;default:''" json:"aupVersion"`
AUPAgreedAt *time.Time `json:"aupAgreedAt"`
// HasPassword is a derived JSON-only field — populated in AfterFind
// via a side query that reads only a boolean projection of the
// password_hash column. The hash itself never lives on this struct;
// see database/credentials.go for the full reasoning. Not stored.
HasPassword bool `gorm:"-" json:"hasPassword"`
// CreatedBy is the user ID of whoever caused this record to exist,
// or one of the sentinels CreatorSelfEnrolled / CreatorUnknown. See
// the Creator struct for the audit fields recorded together at
// every create site.
CreatedBy string `gorm:"not null;default:'unknown'" json:"createdBy"`
CreatorAuthMethod AuthMethod `gorm:"not null;default:''" json:"creatorAuthMethod"`
CreatorAuthMethodID string `gorm:"not null;default:''" json:"creatorAuthMethodId,omitempty"`
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`
// DeletedAt is the soft-delete tombstone. GORM auto-excludes rows where
// it is non-NULL from ordinary queries; callers needing to surface
// deleted users (audit, history) must use db.Unscoped(). See the
// 20260425120000_user_soft_delete migration and the contract comment
// above for the why.
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
User is the canonical user record. Four concepts live on this row and they are intentionally distinct — code that conflates them is a bug.
Field Purpose Mutability Used for authz?
----- ------- ---------- ---------------
ID Opaque internal primary key. Immutable; NO — never.
Auto-generated; never reused — never reused
soft-deletes flag the row, they (delete is a
do not actually remove it. soft delete).
ID DOES leak into URLs and JSON
responses (json:"id"). The design
doc said "should NOT be presented
to the web interface"; the
practical posture this codebase
has settled on is "ID is a routing
handle, never a permission grant."
Specifically:
- admin lists (Server.UIAdminUsers
et al) are matched against
Username only — never ID.
- The user_id claim in the login
cookie is used purely as a
lookup key (GetUserByID) for
re-validating the row exists
and is active. It is NOT
matched against config or
compared with any other ID.
- The cookie's signature is
verified against the local key
AND the issuer/audience are
pinned to Server.ExternalWebUrl,
so an attacker can't forge a
cookie carrying an arbitrary
user_id.
An ID value is therefore safe to
embed in URLs / SWR keys / log
lines: knowing it grants no
authority by itself.
Username Authorization handle. Compared Admin-controlled YES.
against admin lists, group after first login.
memberships, etc. Bootstrapped from
IdP at first login
per the configured
claim list.
DisplayName Human label for the UI. Self-editable; No.
refreshed from the
IdP on each login.
Sub / Issuer Linked OIDC identity for *login* Add/remove as a No — never.
only. Multiple identities per user linked identity
live in user_identities (this row via /identities;
is the primary linkage). not edited inline.
Anything that looks like "use sub for permissions" or "rename the user based on the IdP claim every login" is wrong — see LookupOrBootstrapUser for the correct first-login / return-visit flow.
func CreateLocalUser ¶
func CreateLocalUser(db *gorm.DB, username, displayName, localIssuer string, creator Creator) (*User, error)
CreateLocalUser creates a user record intended for username/password authentication. The user's sub is set to the username and the issuer is the supplied local-issuer URL (typically Server.ExternalWebUrl). The row is created with no password — the only supported way to set a password is the admin-issued password-invite flow (see CreatePasswordInviteLink), so admins never see or pick a user's password.
func CreateUser ¶
func CreateUser(db *gorm.DB, username string, sub string, issuer string, creator Creator) (*User, error)
CreateUser is the admin-driven path for creating a user record. The creator argument records who/how the request was made — see the Creator type. For the OIDC self-enrollment path use LookupOrBootstrapUser instead, which stamps CreatorSelf().
func GetOrCreateUser ¶
func GetOrCreateUser(db *gorm.DB, username string, sub string, issuer string, creator Creator) (*User, error)
GetOrCreateUser is the htpasswd / init-code login path's "make sure this username has a DB row" helper. The user authenticated themselves (with a password / one-time code), so for the create case we mark the resulting row as self-enrolled. Callers should pass CreatorSelf() to be explicit; the function nonetheless ignores creator when the user already exists.
func GetUserByIdentity ¶
GetUserByIdentity looks up a user by an identity (sub + issuer), checking both the primary user table and the user_identities table.
func LookupOrBootstrapUser ¶
func LookupOrBootstrapUser(db *gorm.DB, sub, issuer, displayName string, usernameCandidates []string) (*User, error)
LookupOrBootstrapUser is the first-login (and every-subsequent-login) entry point for OIDC authentication.
User-record contract (see comment on the User struct for the full model):
- (sub, issuer) is the *linkage* to the IdP identity. It is what we look up against. It is never used for authorization decisions and is never re-derived from the chosen username.
- Username is the *authorization handle*. On first sight of an identity we bootstrap it from `usernameCandidates` (already resolved by the caller from configured claims, in priority order); if every candidate collides with an existing account we append a short random disambiguator to the first candidate.
- DisplayName is a *human label*. It is refreshed on every login from whatever the IdP currently reports — users who rename themselves at the IdP get a fresh label without needing an admin's help. It does not influence authorization in any way.
On a return-visit (identity already linked) the username is left alone: once an account exists, only an administrator may rename it.
func VerifyUserPassword ¶
VerifyUserPassword looks up a user by (username, issuer) and verifies the supplied plaintext against the stored bcrypt hash. The hash itself never escapes this function. Returns ErrInvalidPassword for any failure mode (unknown user, no password set, inactive, mismatch) so callers cannot distinguish them.
The returned *User goes through the standard GetUserByID pipeline, which means it carries no PasswordHash field — only the HasPassword bool that callers are allowed to see.
func (*User) AfterFind ¶
AfterFind populates the derived HasPassword field on every User load by issuing a single boolean-projection query against the users table. Done in the hook (rather than at each call site) so handlers can't forget — every code path that reads a User out of the DB sees the flag set correctly. The hash itself never enters the User struct; see database/credentials.go for the security contract.
This adds one extra round-trip per loaded User. Acceptable for the admin-side surfaces this powers; if hot lists become a problem, batch the lookup with a single "id IN ? AND password_hash <> ”" query.
func (*User) HasLocalPassword ¶
HasLocalPassword reports whether the user can log in via username/password. Backed by the same projection populated into HasPassword by AfterFind.
type UserCard ¶
type UserCard struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
}
UserCard is a small, non-sensitive summary of a user — just enough to render "Display Name (username)" without leaking the full User record. Used when the requester needs to see who owns / created / administers a group without being granted general user-listing privileges.
func CollectionCandidateOwners ¶
func CollectionCandidateOwners(db *gorm.DB, coll *Collection) ([]UserCard, error)
CollectionCandidateOwners returns the set of users who could plausibly be made the owner of the supplied collection — used to populate the owner-picker on the edit page WITHOUT having to expose the full users list to non-user-admin callers. The set is the union of:
- The current owner (so the picker can render "Display Name (username)" for the current value, even if the caller has no other relationship to the row).
- Members of the collection's admin group, if AdminID is set.
- Members of every group attached via a CollectionACL row, regardless of read/write/owner role.
Returns UserCard rows so the caller never sees more than the public-safe (id, username, displayName) projection. Soft-deleted users are filtered out by GORM's default scope.
type UserIdentity ¶
type UserIdentity struct {
ID string `gorm:"primaryKey" json:"id"`
UserID string `gorm:"not null;uniqueIndex:idx_user_identities_user_issuer" json:"userId"`
// Sub + Issuer is unique globally (no two users share an identity)
// AND user_id + Issuer is unique (one identity per issuer per user).
// Both invariants matter; both are enforced by indexes.
Sub string `gorm:"not null;uniqueIndex:idx_identity_sub_issuer" json:"sub"`
Issuer string `gorm:"not null;uniqueIndex:idx_identity_sub_issuer;uniqueIndex:idx_user_identities_user_issuer" json:"issuer"`
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`
}
func CreateUserIdentity ¶
func CreateUserIdentity(db *gorm.DB, userID, sub, issuer string) (*UserIdentity, error)
CreateUserIdentity associates a new identity (sub + issuer) with an existing user.
func ListUserIdentities ¶
func ListUserIdentities(db *gorm.DB, userID string) ([]UserIdentity, error)
ListUserIdentities returns all identities for a given user.
type UserScope ¶
type UserScope struct {
UserID string `gorm:"primaryKey" json:"userId"`
Scope token_scopes.TokenScope `gorm:"primaryKey;column:scope" json:"scope"`
GrantedBy string `gorm:"not null;default:'unknown'" json:"grantedBy"`
AuthMethod AuthMethod `gorm:"not null;default:''" json:"authMethod"`
AuthMethodID string `gorm:"not null;default:''" json:"authMethodId,omitempty"`
GrantedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"grantedAt"`
}
UserScope is one row in the user_scopes table — a scope granted directly to a single user.
type UserStatus ¶
type UserStatus string
const ( UserStatusActive UserStatus = "active" UserStatusInactive UserStatus = "inactive" )
type Visibility ¶
type Visibility string
const ( VisibilityPrivate Visibility = "private" VisibilityPublic Visibility = "public" )