database

package
v0.0.0-...-39af9f5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 49 Imported by: 0

Documentation

Index

Constants

View Source
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.

View Source
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.

View Source
const (

	// naclNonceSize is the byte length of a NaCl box nonce.
	NaclNonceSize = 24
)

Variables

View Source
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")
)
View Source
var EmbedOriginMigrations embed.FS
View Source
var EmbedRegistryMigrations embed.FS
View Source
var EmbedUniversalMigrations embed.FS
View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
var ServerDatabase *gorm.DB

Functions

func AddCollectionMembers

func AddCollectionMembers(db *gorm.DB, id string, members []string, addedBy, addedByID string, groups []string, isAdmin bool) error

func AddGroupMember

func AddGroupMember(db *gorm.DB, groupId, userId, addedByUserId string, isAdmin bool) error

func ApplyDefaultUserScopes

func ApplyDefaultUserScopes(db *gorm.DB, userID string, granter Creator)

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

func AutoMigrateCredentialsForTests(db *gorm.DB) error

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

func BackfillNewUserDefaultScopes(db *gorm.DB) error

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

func BootstrapAdminAndBackfillOwners(db *gorm.DB) error

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

func CanManageGroup(db *gorm.DB, group *Group, userID string, isSystemAdmin bool) bool

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

func CleanNamespacePath(ns string) (string, error)

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:

  1. 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.

  2. 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

func ClearAUPAgreement(db *gorm.DB, userID string) error

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

func ClearMasterKeyRows(ctx context.Context, db *gorm.DB) error

ClearMasterKeyRows removes all rows from server_master_keys.

func CreateBackup

func CreateBackup(ctx context.Context) error

CreateBackup creates a compressed and encrypted backup of the database. This is the exported entry point for the CLI.

func CreateCounter

func CreateCounter(key string, value int) error

func CreateDowntime

func CreateDowntime(downtime *server_structs.Downtime) error

CRUD operations for downtimes table Create a new downtime entry

func CreateOrUpdateCounter

func CreateOrUpdateCounter(key string, value int) error

func DecryptMasterKey

func DecryptMasterKey(encryptedBlob []byte, serverKey jwk.Key) ([]byte, error)

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 DeleteCollection(db *gorm.DB, id string, owner, ownerID string, groups []string, isAdmin bool) error

func DeleteCollectionMetadata

func DeleteCollectionMetadata(db *gorm.DB, id, user, userID string, groups []string, key string, isAdmin bool) error

func DeleteDowntime

func DeleteDowntime(uuid string) error

Delete a downtime entry by UUID (hard delete)

func DeleteGroup

func DeleteGroup(db *gorm.DB, groupID, requestorUserID string, isAdmin bool) error

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

func DeleteMasterKeyRows(ctx context.Context, db *gorm.DB, fingerprints []string) error

DeleteMasterKeyRows removes rows whose fingerprints are in the given list.

func DeleteUser

func DeleteUser(db *gorm.DB, userID, requestorUserID string, isAdmin bool) error

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

func DeleteUserIdentity(db *gorm.DB, identityID, userID string) error

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

func DeriveSubKey(masterKey []byte, purpose string, length int) ([]byte, error)

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:

  1. user_scopes for userID (direct grants).
  2. group_scopes for every group the user is a row-member of (transitive via group_members).
  3. 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

func EncryptMasterKey(masterKey []byte, serverKey jwk.Key) ([]byte, error)

EncryptMasterKey encrypts the master key using a NaCl box derived from the given server private key. Returns nonce (24 bytes) || ciphertext.

func ExpandCallerACLGroups

func ExpandCallerACLGroups(db *gorm.DB, user, userID string, groups []string) []string

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

func FilterAuthTemplateEligibleGroups(db *gorm.DB, names []string) []string

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

func GetGroupCards(db *gorm.DB, ids []string) (map[string]GroupCard, error)

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

func GetUserCards(db *gorm.DB, ids []string) (map[string]UserCard, error)

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 GrantCollectionAcl(db *gorm.DB, id, user, userID string, groups []string, groupId string, role AclRole, expiresAt *time.Time, isAdmin bool) error

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

func HashAUPContent(content string) string

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

func IsACLGroupVirtual(name string) bool

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

func LaunchPeriodicBackup(ctx context.Context, egrp *errgroup.Group)

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

func LeaveGroup(db *gorm.DB, groupID, userID string) error

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

func LoadMasterKeyRows(ctx context.Context, db *gorm.DB) (map[string][]byte, error)

LoadMasterKeyRows returns all rows from server_master_keys as a map[keyFingerprint] → encryptedBlob.

func LoadOrCreateMasterKey

func LoadOrCreateMasterKey(db *gorm.DB) ([]byte, error)

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

func RecordAUPAgreement(db *gorm.DB, userID string, version string) error

RecordAUPAgreement records that a user agreed to a specific version of the AUP.

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(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(db *gorm.DB, plaintext, newPassword string) (string, error)

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 RemoveCollectionMembers(db *gorm.DB, id string, members []string, user, userID string, groups []string, isAdmin bool) error

func RemoveGroupMember

func RemoveGroupMember(db *gorm.DB, groupId, userId, removedByUserId string, isAdmin bool) error

func RenameUser

func RenameUser(db *gorm.DB, id, newUsername, localIssuer string) error

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

func RestoreFromBackup(dbPath string) (bool, error)

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

func RestoreFromSpecificBackup(dbPath, backupPath string, force bool) error

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 RevokeCollectionAcl(db *gorm.DB, id, user, userID string, groups []string, groupId string, role AclRole, isAdmin bool) error
func RevokeGroupInviteLink(db *gorm.DB, linkID, requestorUserID string, isSystemAdmin bool) error

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

func SanitizeIdentifier(s string) string

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

func SetUserPassword(db *gorm.DB, userID, plaintext string) error

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

func SetupMockDowntimeDB(t *testing.T)

Test helper functions for Downtime

func ShutdownDB

func ShutdownDB() error

func SoftDeleteServerLocalMetadata

func SoftDeleteServerLocalMetadata(id string) error

Mark a server local metadata as deleted without actually removing it from the database

func SyncMasterKeyRows

func SyncMasterKeyRows(db *gorm.DB, masterKey []byte, currentKeys map[string]jwk.Key) error

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 TeardownMockDowntimeDB(t *testing.T)

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 UpdateUser

func UpdateUser(db *gorm.DB, id string, username, sub, issuer *string) error

func UpdateUserDisplayName

func UpdateUserDisplayName(db *gorm.DB, userID string, displayName string) error

UpdateUserDisplayName updates the display name of a user.

func UpdateUserLastLogin

func UpdateUserLastLogin(db *gorm.DB, userID string) error

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 UpsertCollectionMetadata

func UpsertCollectionMetadata(db *gorm.DB, id, user, userID string, groups []string, key, value string, isAdmin bool) error

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

func ValidateDisplayName(name string) error

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

func ValidateIdentifier(name string) error

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

func VerifyBackup(backupPath string) error

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
const (
	AclRoleRead  AclRole = "read"
	AclRoleWrite AclRole = "write"
	AclRoleOwner AclRole = "owner"
)

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

func MinRole(a, b AclRole) AclRole

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 AdminType

type AdminType string
const (
	AdminTypeUser  AdminType = "user"
	AdminTypeGroup AdminType = "group"
)

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 GetCollection(db *gorm.DB, id string, user, userID string, groups []string, isAdmin bool) (*Collection, error)

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:

  1. Public collections (visibility=public).
  2. 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.
  3. 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.
  4. 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

func GetCollectionAcls(db *gorm.DB, id, user, userID string, groups []string, isAdmin bool) ([]CollectionACL, error)

type CollectionMember

type CollectionMember struct {
	CollectionID string    `gorm:"primaryKey" json:"collectionId"`
	ObjectURL    string    `gorm:"primaryKey" json:"objectUrl"` // full pelican:// URL
	AddedBy      string    `gorm:"not null" json:"createdBy"`
	AddedAt      time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"createdAt"`
}

func GetCollectionMembers

func GetCollectionMembers(db *gorm.DB, id, user, userID string, groups []string, since *time.Time, limit int) ([]CollectionMember, error)

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

func GetCollectionMetadata(db *gorm.DB, id, user, userID string, groups []string) ([]CollectionMetadata, error)

type Counter

type Counter struct {
	Key   string `gorm:"primaryKey"`
	Value int    `gorm:"not null;default:0"`
}

type CreateShareReq

type CreateShareReq struct {
	ParentCollectionID string
	Name               string
	Description        string
	Namespace          string
	Visibility         Visibility
	// 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.
	OwnerUsername string
	OwnerID       string
}

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 GetGroupWithMembers

func GetGroupWithMembers(db *gorm.DB, groupId string) (*Group, error)

func GetMemberGroups

func GetMemberGroups(db *gorm.DB, userId string) ([]Group, error)

func ListGroups

func ListGroups(db *gorm.DB) ([]Group, error)

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

type GroupCard struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

GroupCard is the analogous summary for a group (e.g. when a group itself is the administrator of another group).

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(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(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(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(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(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 = 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 GetUserByID

func GetUserByID(db *gorm.DB, id string) (*User, error)

func GetUserByIdentity

func GetUserByIdentity(db *gorm.DB, sub, issuer string) (*User, error)

GetUserByIdentity looks up a user by an identity (sub + issuer), checking both the primary user table and the user_identities table.

func GetUserByUsername

func GetUserByUsername(db *gorm.DB, username string) (*User, error)

func ListUsers

func ListUsers(db *gorm.DB) ([]User, error)

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

func VerifyUserPassword(db *gorm.DB, username, plaintext, issuer string) (*User, error)

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

func (u *User) AfterFind(tx *gorm.DB) error

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

func (u *User) HasLocalPassword() bool

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.

func ListUserScopes

func ListUserScopes(db *gorm.DB, userID string) ([]UserScope, error)

ListUserScopes returns all scope grants for 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"
)

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL