organization

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

github.com/ubgo/auth/plugin/organization

Adds workspaces to an app, matching better-auth v1.7.7's organization plugin: users create organizations, invite colleagues by email, and hold roles (owner, admin, member, the app's own, or roles each organization creates) whose permission statements every operation and the app's own routes check. Optional: an active organization per session, teams, dynamic roles and lifecycle hooks.

Install

go get github.com/ubgo/auth/plugin/organization

This is its own module because organizations are not authentication: an app with no groups never carries the code, and the plugin can be redesigned without touching the core. It brings no third-party dependency: the core, plugin/access (the permission vocabulary, its own module) and github.com/ubgo/sqlb.

Wire it

Taken from examples/organizations:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		password.New(),
		organization.New(),
	),
)
// then: a.Migrate(ctx), and mount authhttp.Handler(a, authhttp.Prefix("/auth"))

Guard the app's own routes by role in the organization the request names:

orgs, err := auth.PluginOf[*organization.Plugin](a)
mw := authhttp.Middleware(a)
orgInPath := func(r *http.Request) string { return r.PathValue("org") }
mux.Handle("GET /orgs/{org}/billing", mw(orgs.RequireRole(orgInPath, organization.RoleAdmin)(billing)))

RequireRole must sit inside authhttp.Middleware. A signed-out request gets 401 auth.signed_out, a non-member 404 organization.not_member, and a lower role 403 organization.forbidden.

auth.New fails when:

Condition Error text
The email plugin is missing, and invitations are addressed by email (the default) "organization" requires "email" (from Requires)
A plugin named email registers no email identifier kind invitations are addressed by email; add the email plugin
InviteBy names a kind no plugin registers organization: invitations are addressed by "phone", which no plugin registers
InviteBy names a kind whose plugin cannot deliver (username) organization: invitations are addressed by "username", whose plugin cannot deliver messages: … (errors.Is(err, auth.ErrNoDeliverer))
InviteBy() with no kind organization: InviteBy needs at least one identifier kind; use WithoutInvitations to turn invitations off
Neither auth.WithBaseURL nor organization.InviteLink is set, with invitations on organization: invitation links need auth.WithBaseURL or organization.InviteLink

Invitations need not be email. organization.InviteBy(phone.KindPhone) addresses them by phone in a phone-only app ({"kind":"phone","identifier":"+14155550123"}; email still works and means kind email), delivered through that kind's plugin and accepted only by a user holding the verified identity of that kind and value. organization.WithoutInvitations() unmounts the invitation routes for an app that adds members only from Go (AddMember), and then needs no identifier plugin or BaseURL.

The invitation mail is sent through the email plugin with purpose organization.PurposeInvite ("organization.invite") and Message.Link set to the invitations page. Out of the box it reads "You are invited to join an organization", with a short body ending in the link: the plugin sends that wording as Message.DefaultSubject and Message.DefaultText, which the email plugin uses when the app's templates have no case for the purpose. To word it yourself, handle organization.PurposeInvite in email.Templates as the example does. The message's Data is an organization.InvitationData (invitation id, address, role, team, expiry; organization id, name, slug and logo; inviter id, name and primary verified address), read with m.Data.(organization.InvitationData); the plugin is an auth.MessageDataDecoder, so the type survives auth.WithDeliveryOutbox.

Tests: the orgtest package (orgtest.New(env), NewOrganization, SaveOrganization, AddMember) builds and saves organizations for an authtest.Env through the plugin's own Go API.

Options

Option What it does Default
organization.WithInviteTTL(d) How long an invitation stays acceptable 7 days (DefaultInviteTTL)
organization.InviteLink(func() string) The page the invitation mail links to; called per mail BaseURL + /invitations (DefaultInvitePage)
organization.InviteBy(kinds...) Identifier kinds an invitation may be addressed to, such as phone.KindPhone; the first is used when a request names no kind. Each must be registered by a plugin that delivers messages (auth.Config.CanDeliver), checked at auth.New; email requires the email plugin only when listed email.KindEmail
organization.WithoutInvitations() Unmounts invite, invites, accept, invitation, reject, cancel and list-invitations, for members added from Go only invitations on
organization.InviteMessage(func(InviteMail) (subject, text string)) Words the invitation mail from the invitation, the organization, the inviter and the link; an email.Templates case for PurposeInvite still wins the plugin's generic wording
organization.InviteLimit(n, window) Invitations allowed per client address per window 50 per hour (DefaultInviteLimit, DefaultInviteWindow)
organization.ReplacePendingInvites() Inviting an address again withdraws its pending invitations to that organization first, so one invitation and one fresh mail stand off: every invite adds a pending invitation
organization.RefuseRepeatInvites() Inviting an address that already holds a pending, unexpired invitation to the organization is organization.already_invited (better-auth's default), unless the request sets resend; ReplacePendingInvites wins over it off: every invite adds a pending invitation
organization.CreateGate(func(ctx, auth.User) (bool, error)) Decides who may create an organization, for example by plan; false is organization.create_forbidden everyone signed in
organization.MaxOwned(n) At most n organizations owned per user; counted as owner memberships no cap
organization.MaxMembers(n) At most n members per organization, checked when someone joins (accept, AddMember); invitations still go out no cap
organization.InvitationLimit(n) At most n pending invitations per organization (better-auth's invitationLimit); 0 is no cap 100 (DefaultInvitationLimit)
organization.RejectExistingMembers() Inviting or AddMember of someone already in the organization is organization.already_member off: allowed, and accepting keeps the role
organization.CreatorRole(r) The creator's role (better-auth's creatorRole), and the role the owner protections guard RoleOwner
organization.WithAccessControl(ac) The permission vocabulary (access.AccessControl); start from DefaultStatements to keep the plugin's own checks DefaultAccessControl
organization.WithRoles(map[Role]access.Role) Static roles added to, or replacing, DefaultRoles() owner, admin, member
organization.WithAuthorizer(a) Decides every permission (Authorizer: Authorize(ctx, AccessRequest) (bool, error); any error refuses): each operation's check, has-permission, Can, RequirePermission, the grant check of a dynamic role and APIKeyPermission. Wrap p.RoleAuthorizer() to add a rule to the roles. Under a custom authorizer the creator's API-key shortcut is off RoleAuthorizer()
organization.Teams(TeamsConfig{MaxTeams, MaxMembersPerTeam, AllowRemovingAllTeams, NoDefaultTeam, DefaultTeamName}) Teams inside an organization; a default team with the creator unless NoDefaultTeam off
organization.DynamicRoles(DynamicRolesConfig{MaxRoles}) Roles created per organization at run time (better-auth's dynamic access control) off
organization.Domains(DomainsConfig{Verifier, RecordPrefix, TrustVerifiedEmail, IsPublicDomain, MaxDomains}) Verified domains: an organization claims an email domain, proves it (a DNS TXT record through the replaceable DomainVerifier, or with TrustVerifiedEmail the admin's own verified address at it) and enrolls users whose email there becomes verified, by the domain's enrollment mode; public mail domains are refused (IsDefaultPublicDomain unless replaced); 10 domains per organization (DefaultMaxDomains) off
organization.WithFields(FieldsConfig{Org, Member, Invitation, Team, Role}) Extra columns per table (better-auth's additionalFields), each a Field{Name, Type, Nullable, Default, Required, ServerOnly}, read and written as a fields object; one migration per field none
organization.WithHooks(Hooks{…}) Typed Before/After hooks on organizations, members, invitations, teams and team members, inside the operation's transaction; a Before hook may change the row none
organization.WithHooksFrom(func(a *auth.Auth) Hooks) The same hooks, built in Init with the *auth.Auth, so a hook writes the app's rows through a.Conn(ctx) in the operation's transaction; refused together with WithHooks none

Validate refuses InviteLimit with a limit below 1 or a non-positive window (organization: InviteLimit needs a positive limit and window), a CreatorRole that is not one static role, a WithRoles role whose permissions the access control does not declare, a nil access control, a nil authorizer (auth.ErrNilOption naming organization.WithAuthorizer, as for every option given nil), and a negative InvitationLimit, team limit or MaxRoles.

Routes and Go API

Routes are under the mount prefix (/auth below). A Query is served as GET with its input in the query string; a Mutation as POST with a JSON body.

Route Go method Requirement Rate limit
POST /auth/organization/create {name, slug, logo, metadata, keepCurrentActiveOrganization} Create(ctx, CreateRequest) SignedIn; CreateGate and MaxOwned apply; becomes the session's active organization none
GET /auth/organization/check-slug?slug= CheckSlug(ctx, slug) SignedIn none
GET /auth/organization/list List(ctx) SignedIn none
GET /auth/organization/members?orgId=&page=&limit=&sortBy=&sortDirection=&filterField=&filterOperator=&filterValue= Members(ctx, orgID), ListMembers(ctx, OrgRequest) SignedIn, member none
GET /auth/organization/role?orgId= Role(ctx, orgID) SignedIn, member none
POST /auth/organization/invite {orgId, email, role, teamId, resend} Invite(ctx, InviteRequest) SignedIn, invitation:create; only a creator-role holder invites with that role; InvitationLimit 50 per hour per IP by default (InviteLimit)
GET /auth/organization/invites Invites(ctx) SignedIn none
GET /auth/organization/invite?inviteId= Invitation(ctx, inviteID) SignedIn, verified invited address none
POST /auth/organization/accept {inviteId} Accept(ctx, inviteID) SignedIn, verified invited address; MaxMembers applies none
POST /auth/organization/remove-member {orgId, userId}, {orgId, email} or {orgId, kind, identifier} RemoveMember(ctx, MemberRequest) SignedIn; the caller leaving, or member:delete; only a creator-role holder removes one none
POST /auth/organization/set-role {orgId, userId, role} (role a string or an array) SetRole(ctx, SetRoleRequest) SignedIn, member:update; only a creator-role holder grants it or changes a holder none
GET /auth/organization/get-organization?orgId= or ?orgSlug= Get(ctx, GetRequest) SignedIn, member none
GET /auth/organization/get-full-organization?orgId=&membersLimit= GetFull(ctx, GetRequest) SignedIn, member none
POST /auth/organization/update {orgId, data: {name, slug, logo, metadata}} Update(ctx, UpdateRequest) SignedIn, organization:update none
POST /auth/organization/delete {orgId} Delete(ctx, orgID) SignedIn, organization:delete none
POST /auth/organization/set-active {orgId} or {orgSlug}, {} to clear SetActive(ctx, SetActiveRequest) SignedIn, member, a session (not an API key) none
GET /auth/organization/active Active(ctx) SignedIn none
POST /auth/organization/reject-invitation {inviteId} Reject(ctx, inviteID) SignedIn, verified invited address none
POST /auth/organization/cancel-invitation {inviteId} Cancel(ctx, inviteID) SignedIn, invitation:cancel none
GET /auth/organization/list-invitations?orgId= ListInvitations(ctx, orgID) SignedIn, member none
POST /auth/organization/has-permission {orgId, permissions, connector} HasPermission(ctx, PermissionRequest) SignedIn, member none
POST /auth/organization/create-team, GET list-teams, POST update-team, POST remove-team, POST set-active-team, GET list-user-teams, GET list-team-members, POST add-team-member, POST remove-team-member CreateTeam, ListTeams, UpdateTeam, RemoveTeam, SetActiveTeam, ListUserTeams, ListTeamMembers, AddTeamMember, RemoveTeamMember With Teams only; team:create, team:update, team:delete as the guide lists none
POST /auth/organization/create-role, POST update-role, POST delete-role, GET get-role, GET list-roles CreateRole, UpdateRole, DeleteRole, GetRole, ListRoles With DynamicRoles only; ac:create, ac:update, ac:delete, ac:read none
POST /auth/organization/add-domain {orgId, domain, enrollmentMode, role}, GET list-domains?orgId=, POST verify-domain {domainId}, POST update-domain {domainId, enrollmentMode, role}, POST remove-domain {domainId} AddDomain, ListDomains, VerifyDomain, UpdateDomain, RemoveDomain With Domains only; list needs membership, the rest organization:update; a domain never grants the creator role verify-domain: 10 per minute per client address (DefaultVerifyLimit, DefaultVerifyWindow)
GET /auth/organization/domain-suggestions, POST accept-suggestion {domainId}, GET list-membership-requests?orgId=, POST approve-membership-request {requestId}, POST reject-membership-request {requestId} DomainSuggestions, AcceptSuggestion, ListMembershipRequests, ApproveMembershipRequest, RejectMembershipRequest With Domains only; suggestions are the caller's own verified emails; requests need member:create, and approval checks again that the domain and the requester's address are still verified none

Every operation that takes orgId uses the session's active organization when it is left out, and answers organization.no_active when there is none.

invite with resend: true, when the address already holds a pending invitation to the organization, mails that invitation again with a fresh expiry instead of creating another (better-auth's resend): it keeps its id, role and team, the caller needs invitation:create and, for an invitation carrying the creator role, that role; with nothing pending it is an ordinary invite. members filters with better-auth's filterField, filterOperator and filterValue: on role, userId and name the operators eq, ne, in, not_in (comma-separated values), contains, starts_with and ends_with, all case-sensitive on every engine (GLOB on SQLite, whose LIKE ignores case) with % and _ taken as text; on joinedAt (or createdAt, the same column) an RFC 3339 instant with eq, ne, lt, lte, gt and gte. lt/gt on text are organization.bad_query, because the order of two names depends on each engine's collation. list-team-members without teamId lists the session's active team (organization.no_active_team when there is none).

check-slug answers {slug, available} with the normalized slug; a slug outside SlugPattern is not available. members pages like the admin list: limit defaults to 50 (DefaultMembersPage) and is clamped to 200 (MaxMembersPage); the result carries total, page and limit. remove-member by email resolves a verified address; an unknown or unverified one is organization.not_member, like an unknown id.

Operation ids are the Endpoint* constants (organization.create and so on). From Go, with no transport:

Function Use
(*Plugin).RoleOf(ctx, orgID, userID) The user's role, or ErrNotMember
(*Plugin).Can(ctx, orgID, userID, access.Request, connector) Whether the authorizer (the user's roles by default) allows the request, or ErrNotMember
(*Plugin).RequirePermission(orgID func(*http.Request) string, access.Request) Middleware like RequireRole, by permission
(*Plugin).ActiveOrgID(ctx) The request's active organization
(*Plugin).AddMemberWith(ctx, AddMemberRequest{OrgID, UserID, Role, Fields}) AddMember with member fields
(*Plugin).CreateFor(ctx, userID, CreateRequest) Creates an organization owned by userID from trusted code (a seed, an import); CreateGate and MaxOwned do not apply, the slug rules do
(*Plugin).AddMember(ctx, orgID, userID, role) Adds a member without an invitation; an existing member keeps their role; MaxMembers applies
(*Plugin).RequireRole(orgID func(*http.Request) string, role) Middleware, shown above
(*Plugin).Sweep(ctx, cutoff) Deletes invitations that expired unaccepted before cutoff (accepted ones stay) and the active selections of expired sessions. Run by auth sweep
(*Plugin).ListAll(ctx, AllOrgsRequest{Page, Limit, Search}) Every organization of the app, newest first, with MemberCount, paged like the members list; Search matches inside the slug
(*Plugin).MarkDomainVerified(ctx, domainID) Verifies a domain from trusted code with no proof, for an operator who checked ownership another way; never a route. Another organization's verified domain is still organization.domain_taken
NormalizeDomain(raw), EmailDomain(addr), DNSVerifier{Resolver}, IsDefaultPublicDomain(d) The domain rules behind the operations, for an app's own screens or a verifier that wraps the DNS one
(*Plugin).GetFor, UpdateFor, DeleteFor, MembersFor, SetRoleFor, RemoveMemberFor, InvitationsFor, CancelFor The platform API: the same operations on any organization, for an operator's admin screen (better-auth's dashboard organization overview). No session or membership is checked, so call them behind the app's own admin gate (for example admin.RequireAdmin); the slug rules, the last-owner rule (ErrLastOwner) and every hook still apply, and each runs in one transaction. They are Go calls, so they bypass the operation pipeline and its audit record, as CreateFor and AddMember do
MembersQuery, MembersPageQuery, PendingInvitesQuery, MembershipQuery The typed statements behind the operations, for an app's own queries
UserJoin(orgID) + UserBindings(&UserMembership{}) Adds one organization's membership to a composite user query (LEFT join; IsMember() is false for a non-member)

Built-in roles, most privileged first: RoleOwner, RoleAdmin, RoleMember (RoleValues), with better-auth's permissions (DefaultStatements, DefaultRoles(); resources organization, member, invitation, team, ac). A stored role may hold several, comma-joined (Role.Parts, Role.Has, JoinRoles); a member passes a check when any one of their roles does. Role.AtLeast(need) compares the built-in ranks; a custom role ranks 0.

Tables and columns

Table (alias) Columns Keys
auth_org (og) id, slug, name, created_at, logo, metadata id primary key, slug unique
auth_org_member (om) org_id, user_id, role, joined_at primary key (org_id, user_id); no foreign keys: a.DeleteUser removes a user's memberships (UserTables), and removing a member is the plugin's own operation
auth_org_invite (oi) id, org_id, email (the address of an email invitation, "" for another kind), identifier_kind, identifier (every invitation's kind and normalized value), role, invited_by, expires_at, accepted_at, created_at, status, team_id id primary key; no foreign keys
auth_org_active (oa) session_id, user_id, org_id, team_id, expires_at session_id primary key, index on user_id; a user table (a.DeleteUser), cleared at sign-out (auth.SessionLifecycle) and by Sweep
auth_org_team (ot), with Teams id, org_id, name, created_at, updated_at id primary key
auth_org_team_member (otm), with Teams team_id, user_id, created_at primary key (team_id, user_id); a user table
auth_org_role (orl), with DynamicRoles id, org_id, role, permission (JSON), created_at, updated_at id primary key, unique (org_id, role)
auth_org_domain (odm), with Domains id, org_id, domain, verified_domain (NULL until proved), enrollment, role, token, created_by, verified_at, created_at id primary key, unique (org_id, domain), verified_domain unique: many organizations may claim a domain, only the first to prove it owns it
auth_org_domain_request (odr), with Domains id, org_id, user_id, domain_id, status (pending, approved, rejected), created_at, decided_at, decided_by id primary key, unique (org_id, user_id); a user table

The plugin also reads auth_identity under the alias oid to match invitations to verified addresses. Migrations, on Postgres, MySQL and SQLite: 0001_organization (the first three tables), 0002_organization_fields (logo, metadata, status, team), 0003_organization_active, 0004_organization_teams and 0005_organization_roles when their option is on, so turning one on later migrates then, and 0006_organization_invite_identifier (the invitation's kind and identifier, filled from email for existing rows). Table names follow auth.RenameTable and auth.WithTablePrefix. Typed columns (OrgID, MemberRole, InviteEmail, …) and the Col* name constants are exported for joins. The plugin's tables are not in the stability contract (stability_test.go covers only auth_user, auth_identity and auth_session), so treat their columns as stable only by convention within a version.

A slug is 2 to 48 lowercase letters, digits and dashes, not starting with a dash (SlugPattern), and is lowercased before it is checked.

Errors

Error variable Code HTTP When
ErrNotMember organization.not_member 404 The caller (or the target) is not a member; 404 so a stranger cannot tell the organization exists
ErrForbidden organization.forbidden 403 The caller's role is too low
ErrSlugTaken organization.slug_taken 409 Another organization has the slug
ErrBadSlug organization.bad_slug 400 The slug does not match SlugPattern
ErrBadRole organization.bad_role 400 The role is not one of RoleValues
ErrLastOwner organization.last_owner 409 Removing or demoting the only owner
ErrInviteInvalid organization.invite_invalid 404 Unknown, expired, already accepted, or not addressed to a verified address of the caller
ErrCreateForbidden organization.create_forbidden 403 The CreateGate refused the caller
ErrLimitReached organization.limit_reached 409 The caller already owns MaxOwned organizations
ErrMembersLimit organization.members_limit 409 The organization already has MaxMembers members
ErrNoActiveOrg organization.no_active 400 No orgId and no active organization
ErrAlreadyMember organization.already_member 409 With RejectExistingMembers
ErrInvitationLimit organization.invitation_limit 409 InvitationLimit pending invitations
ErrBadMetadata organization.bad_metadata 400 Metadata not a JSON object, or over MaxMetadataSize
ErrBadQuery organization.bad_query 400 An unsupported members sort or filter
ErrTeamNotFound organization.team_not_found 404 Unknown team, or one of an organization the caller is not in
ErrTeamsLimit, ErrTeamMembersLimit, ErrLastTeam organization.teams_limit, organization.team_members_limit, organization.last_team 409 Team limits
ErrRoleNotFound organization.role_not_found 404 Unknown dynamic role
ErrRoleTaken, ErrRolesLimit, ErrRoleInUse organization.role_taken, organization.roles_limit, organization.role_in_use 409 Dynamic role name in use, MaxRoles, or held by members
ErrBadPermission organization.bad_permission 400 A permission outside the statements, or one the caller does not hold
ErrPredefinedRole organization.predefined_role 400 delete-role or update-role naming a configured role (owner, admin, member, WithRoles)
ErrNoActiveTeam organization.no_active_team 400 list-team-members with no teamId and no active team
ErrAlreadyInvited organization.already_invited 409 With RefuseRepeatInvites, the address already holds a pending invitation
ErrInviteAddress organization.invite_address 400 An invitee or member named by both email and identifier, or by a kind InviteBy does not allow
ErrDomainNotFound organization.domain_not_found 404 Unknown domain, or one of an organization the caller is not in
ErrBadDomain organization.bad_domain 400 Not a domain name of two or more labels (NormalizeDomain)
ErrPublicDomain organization.public_domain 400 A public mail domain (DomainsConfig.IsPublicDomain)
ErrDomainTaken organization.domain_taken 409 Another organization proved the domain, or this one added it already
ErrDomainsLimit organization.domains_limit 409 MaxDomains domains
ErrDomainNotVerified organization.domain_not_verified 409 verify-domain found no matching TXT record yet
ErrDomainCheckFailed organization.domain_check_failed 503 The verifier could not look (a DNS outage)
ErrBadEnrollment organization.bad_enrollment 400 An enrollment mode outside EnrollmentModeValues
ErrDomainRole organization.domain_role 400 A domain given the creator role
ErrRequestInvalid organization.request_invalid 404 A suggestion or membership request that is unknown, not the caller's, decided, or whose domain or address is no longer verified
Against better-auth's error codes

better-auth's organization plugin has 60 codes. Every case it reports has a code here; where several of its codes became one, it is one of three folds, each on purpose:

Fold better-auth codes Here Why
A stranger cannot tell what exists ORGANIZATION_NOT_FOUND, MEMBER_NOT_FOUND, USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION, YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION organization.not_member (404) An unknown organization and one the caller may not see answer alike, so a guessed id reveals nothing
INVITATION_NOT_FOUND, YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION, EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION, EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION, FAILED_TO_RETRIEVE_INVITATION, and expired, accepted, rejected or canceled organization.invite_invalid (404) Telling the holder of a guessed id that it is addressed to someone else, or still waits for a verification, confirms that the invitation and its address exist
TEAM_NOT_FOUND, INVALID_TEAM_ID, YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM, and USER_IS_NOT_A_MEMBER_OF_THE_TEAM on set-active-team organization.team_not_found (404) A team of another organization answers like an unknown one; remove-team-member of someone not in the team answers organization.not_member
One code per kind, the action is the route the 22 YOU_ARE_NOT_ALLOWED_TO_<action> codes (update or delete the organization, invite, cancel, delete or update a member, create, update or delete a team or team member, create, read, list, get, update or delete a role, invite with this role) organization.forbidden (403) The client knows which route it called; the code says the role is too low
Same meaning, one name ORGANIZATION_ALREADY_EXISTS and ORGANIZATION_SLUG_ALREADY_TAKEN; YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER and …WITHOUT_AN_OWNER; YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE and NO_ACTIVE_ORGANIZATION organization.slug_taken, organization.last_owner, organization.no_active better-auth raises both names for the same condition

Two codes have no counterpart, on purpose: MISSING_AC_INSTANCE is a configuration error, refused by Validate at start-up instead of on a request, and TEAM_ALREADY_EXISTS is declared by better-auth but raised by none of its routes. INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION is not ported: better-auth raises it only when an invitation is viewed, while its accept still succeeds, and an invitation that can be accepted is shown as one here.

Security notes

Property Pinned by
An invitation is accepted only by a user who has VERIFIED the invited address; typing the address into an account is not enough TestOrganization_InviteNeedsAVerifiedAddress, TestPendingInvitesQuery_RequiresVerifiedIdentity
The address match is exact on normalized values, with no lower() in SQL TestPendingInvitesQuery_RequiresVerifiedIdentity
An invitation to another kind (InviteBy) matches only a verified identity of that same kind and value TestPendingInvitesQuery_RequiresVerifiedIdentity, TestOrgAddress_PhoneOnlyAppInvitesByPhone
An InviteBy kind whose plugin cannot deliver is refused at auth.New with auth.ErrNoDeliverer; a delivering kind beside it wires TestValidate_InviteKindMustDeliver
An invitation cannot be accepted twice, including by two concurrent requests TestOrganization_RolesAndRemoval, TestOrgCov_ConcurrentAcceptLosesTheRace
Accepting an invitation as an existing member does not change their role TestOrgCov_AcceptingAsAMemberKeepsTheRole
An admin cannot invite above their own role or remove an owner; only an owner changes roles; the last owner stays TestOrgCov_RefusalsAndRoleRules, TestOrganization_RolesAndRemoval
A stranger gets 404, not 403, from RequireRole and from every operation TestMiddleware_ScopeAndRole, TestOrgCov_RequireRoleRefusals
The org id in UserJoin is bound, never interpolated TestUserJoin_BindsOrgIDRatherThanInterpolating
Every database failure in every operation reaches the caller TestOrgCov_EveryDatabaseFailureSurfaces, TestR30_FaultSweep
Every operation checks a permission; only a creator-role holder grants or touches that role; a member cannot create a dynamic role stronger than their own TestR30_PermissionsAndCustomRoles, TestR30_DynamicRoles
A rejected or canceled invitation cannot be accepted; only its addressee rejects it; a stranger canceling learns nothing TestR30_InvitationStates
A hook cannot move a row past a check (ids and the invited address are restored); a failing hook leaves nothing behind TestR30_Hooks, TestR30_EveryHookCanFail
Leaving an organization leaves its teams and drops the active selection; deleting an organization removes every row of it TestR30_Teams, TestR30_ActiveOrganization, TestR30_FieldsGetUpdateDelete

| An extra field is written only when declared and of its column's type; a client cannot set a ServerOnly one; Required is enforced on create | TestR30_AdditionalFields, TestR30_FieldsValidate | | A domain enrolls a user only from a VERIFIED email (an identity created verified, or one whose verified_at is set), only when the organization has proved the domain, and an enrollment refusal (MaxMembers, a hook) never fails the user's own verification | TestDomains_AutoJoinOnlyAfterVerification (examples/org-domains), TestEnroll_IgnoresWhatIsNotAVerifiedEmail | | A proved domain belongs to one organization (the unique verified_domain decides, for a sequential and a concurrent attempt alike; add-domain also refuses a domain already proved); a claim another organization cannot prove blocks nobody; a member cannot add or remove a domain; public mail domains are refused | TestDomains_AutoJoinOnlyAfterVerification | | A suggestion becomes a membership only through an admin's approval, which checks again that the domain and the address are still verified; a rejected user is not suggested again | TestDomains_SuggestionAndInvitation, TestDomains_RequestDecidedConcurrently, TestDomains_ApprovalAfterCreatorRoleChanged | | The DNS record is compared in constant time and exactly; a lookup failure is organization.domain_check_failed, never a pass | TestDNSVerifier |

Limits: an organization's name is not validated beyond trimming. Invitations are addressed by email only. Columns cannot be renamed (tables can, with auth.RenameTable). Extra fields are accepted in a fields object or as top-level keys beside the built-in ones, as better-auth sends them; a field sent both ways is auth.field_invalid, an unknown top-level key auth.bad_request; responses carry them in fields.

Example

Documentation

Overview

Package organization adds workspaces and membership to the auth core.

Unlike the phone and username plugins this one owns real tables, because a workspace is not a way of naming a user — it is a separate entity with its own lifecycle. It is also the plugin that demonstrates the join a plugin makes back INTO the core: a member list is meaningless without the email address that lives on auth_user.

Why the invite lookup is the important code here

The published account-takeover advisories in comparable auth libraries are overwhelmingly invitation bugs: an invitation addressed to an email is matched against an identity the claimant never proved they own. The fix is one predicate, and it is applied in PendingInvitesQuery below.

Example (RelationAuthorizer)

Example_relationAuthorizer plugs a Zanzibar-style relationship check into the organization plugin: alice may delete members of acme because a tuple says so, whatever her role.

package main

import (
	"context"
	"fmt"

	"github.com/ubgo/auth/plugin/access"
	"github.com/ubgo/auth/plugin/organization"
)

// tupleStore is a stand-in for a relationship store such as OpenFGA or
// SpiceDB: a set of (user, relation, object) tuples. With OpenFGA, Check
// becomes the client's Check call, and its error is returned so a store
// that is down refuses.
type tupleStore map[[3]string]bool

func (s tupleStore) Check(_ context.Context, user, relation, object string) (bool, error) {
	return s[[3]string{user, relation, object}], nil
}

// relationAuthorizer maps each permission to a relation on the organization
// object: "can_<action>_<resource>" on "organization:<id>". Every action of
// every resource asked for must hold; Or asks for one resource.
type relationAuthorizer struct{ store tupleStore }

// Authorize asks the store one question per action; an error from the
// store is returned, which refuses.
//
//ds:def id=relationauthorizer-authorize-2qt24ghh owner=@khanakia stability=stable
func (a relationAuthorizer) Authorize(ctx context.Context, req organization.AccessRequest) (bool, error) {
	if len(req.Request) == 0 {
		return false, nil
	}
	anyRes := false
	for res, want := range req.Request {
		ok := len(want.Actions) > 0
		for _, act := range want.Actions {
			has, err := a.store.Check(ctx, "user:"+req.UserID, "can_"+act+"_"+res, "organization:"+req.OrgID)
			if err != nil {
				return false, err
			}
			if want.Connector == access.Or && has {
				ok = true
				break
			}
			ok = ok && has
		}
		if !ok && req.Connector != access.Or {
			return false, nil
		}
		anyRes = anyRes || ok
	}
	return anyRes, nil
}

// Example_relationAuthorizer plugs a Zanzibar-style relationship check into
// the organization plugin: alice may delete members of acme because a tuple
// says so, whatever her role.
func main() {
	authz := relationAuthorizer{store: tupleStore{
		{"user:alice", "can_delete_member", "organization:acme"}: true,
	}}
	_ = organization.New(organization.WithAuthorizer(authz))

	ask := func(user, org string) bool {
		ok, err := authz.Authorize(context.Background(), organization.AccessRequest{OrgID: org, UserID: user,
			Request: access.Request{organization.ResourceMember: access.All(organization.ActionDelete)}})
		return ok && err == nil
	}
	fmt.Println(ask("alice", "acme"), ask("alice", "globex"), ask("bob", "acme"))
}
Output:
true false false

Index

Examples

Constants

View Source
const (
	TableNameDomain        = "auth_org_domain"
	TableNameDomainRequest = "auth_org_domain_request"

	TableAliasDomain        = "odm"
	TableAliasDomainRequest = "odr"
)

Tables of verified domains, declared only when Domains is on.

View Source
const (
	ColDomainID         = "id"
	ColDomainOrgID      = "org_id"
	ColDomainName       = "domain"
	ColDomainVerified   = "verified_domain"
	ColDomainEnrollment = "enrollment"
	ColDomainRole       = "role"
	ColDomainToken      = "token"
	ColDomainCreatedBy  = "created_by"
	ColDomainVerifiedAt = "verified_at"
	ColDomainCreatedAt  = "created_at"
)

Columns of auth_org_domain.

View Source
const (
	ColRequestID        = "id"
	ColRequestOrgID     = "org_id"
	ColRequestUserID    = "user_id"
	ColRequestDomainID  = "domain_id"
	ColRequestStatus    = "status"
	ColRequestCreatedAt = "created_at"
	ColRequestDecidedAt = "decided_at"
	ColRequestDecidedBy = "decided_by"
)

Columns of auth_org_domain_request.

View Source
const (
	EndpointAddDomain      auth.EndpointID = "organization.add-domain"
	EndpointListDomains    auth.EndpointID = "organization.list-domains"
	EndpointVerifyDomain   auth.EndpointID = "organization.verify-domain"
	EndpointUpdateDomain   auth.EndpointID = "organization.update-domain"
	EndpointRemoveDomain   auth.EndpointID = "organization.remove-domain"
	EndpointSuggestions    auth.EndpointID = "organization.domain-suggestions"
	EndpointAcceptSuggest  auth.EndpointID = "organization.accept-suggestion"
	EndpointListRequests   auth.EndpointID = "organization.list-membership-requests"
	EndpointApproveRequest auth.EndpointID = "organization.approve-membership-request"
	EndpointRejectRequest  auth.EndpointID = "organization.reject-membership-request"
)

Operation ids of verified domains, permanent like the others.

View Source
const (
	// DefaultDomainRecordPrefix is the label the TXT record lives under:
	// _auth-verification.acme.com. An underscore label is never a host name,
	// so the record cannot collide with a real one.
	DefaultDomainRecordPrefix = "_auth-verification"
	// DomainRecordValuePrefix starts the TXT record's value, so a person
	// reading the zone knows what the record is for.
	DomainRecordValuePrefix = "auth-domain-verification"
	// DefaultMaxDomains caps the domains of one organization (Clerk's limit):
	// enough for a company with a few brands, and a cap so one admin cannot
	// fill the table. DomainsConfig.MaxDomains changes it.
	DefaultMaxDomains = 10
	// DefaultVerifyLimit and DefaultVerifyWindow bound verify-domain per
	// client address: each call is a DNS lookup, and 10 a minute is plenty for
	// someone waiting on propagation.
	DefaultVerifyLimit  = 10
	DefaultVerifyWindow = time.Minute
)
View Source
const (
	TableNameOrg    = "auth_org"
	TableNameMember = "auth_org_member"
	TableNameInvite = "auth_org_invite"
	// TableNameTeam and TableNameTeamMember hold teams, created only with
	// the Teams option.
	TableNameTeam       = "auth_org_team"
	TableNameTeamMember = "auth_org_team_member"
	// TableNameRole holds roles created at run time per organization,
	// created only with the DynamicRoles option.
	TableNameRole = "auth_org_role"
	// TableNameActive holds each session's active organization and team.
	// Keyed by session id rather than a column on auth_session, so it works
	// with every session store, as plugin/sessiondata does.
	TableNameActive = "auth_org_active"
)

Table names owned by this plugin.

View Source
const (
	TableAliasOrg        = "og"
	TableAliasMember     = "om"
	TableAliasInvite     = "oi"
	TableAliasIdentity   = "oid"
	TableAliasTeam       = "ot"
	TableAliasTeamMember = "otm"
	TableAliasRole       = "orl"
	TableAliasActive     = "oa"
)

Table aliases owned by this plugin. None may collide with the core's reserved aliases or another plugin's; auth.New rejects a collision at wire time rather than letting it surface as an ambiguous column at query time.

View Source
const (
	ColOrgID        = "id"
	ColOrgSlug      = "slug"
	ColOrgName      = "name"
	ColOrgCreatedAt = "created_at"
	ColOrgMetadata  = "metadata"
)

Column names on auth_org.

View Source
const (
	ColMemberOrgID    = "org_id"
	ColMemberUserID   = "user_id"
	ColMemberRole     = "role"
	ColMemberJoinedAt = "joined_at"
)

Column names on auth_org_member.

View Source
const (
	ColInviteID    = "id"
	ColInviteOrgID = "org_id"
	// ColInviteEmail holds the invited address when the invitation is
	// addressed by email, and "" for any other kind: better-auth's email
	// field, kept so a reader of email invitations needs no change.
	ColInviteEmail = "email"
	// ColInviteKind and ColInviteIdentifier address the invitation for every
	// kind (InviteBy): the identifier kind and its normalized value. They are
	// the pair PendingInvitesQuery matches against a verified identity; for
	// an email invitation ColInviteIdentifier equals ColInviteEmail.
	ColInviteKind       = "identifier_kind"
	ColInviteIdentifier = "identifier"
	ColInviteRole       = "role"
	ColInviteInvitedBy  = "invited_by"
	ColInviteExpiresAt  = "expires_at"
	ColInviteAccepted   = "accepted_at"
	ColInviteCreatedAt  = "created_at"
	ColInviteStatus     = "status"
	ColInviteTeamID     = "team_id"
)

Column names on auth_org_invite.

View Source
const (
	ColTeamID        = "id"
	ColTeamOrgID     = "org_id"
	ColTeamName      = "name"
	ColTeamCreatedAt = "created_at"
	ColTeamUpdatedAt = "updated_at"
)

Column names on auth_org_team.

View Source
const (
	ColTeamMemberTeamID    = "team_id"
	ColTeamMemberUserID    = "user_id"
	ColTeamMemberCreatedAt = "created_at"
)

Column names on auth_org_team_member.

View Source
const (
	ColRoleID         = "id"
	ColRoleOrgID      = "org_id"
	ColRoleName       = "role"
	ColRolePermission = "permission"
	ColRoleCreatedAt  = "created_at"
	ColRoleUpdatedAt  = "updated_at"
)

Column names on auth_org_role.

View Source
const (
	ColActiveSessionID = "session_id"
	ColActiveUserID    = "user_id"
	ColActiveOrgID     = "org_id"
	ColActiveTeamID    = "team_id"
	ColActiveExpiresAt = "expires_at"
)

Column names on auth_org_active.

View Source
const (
	EndpointCreate       auth.EndpointID = "organization.create"
	EndpointList         auth.EndpointID = "organization.list"
	EndpointMembers      auth.EndpointID = "organization.members"
	EndpointInvite       auth.EndpointID = "organization.invite"
	EndpointInvites      auth.EndpointID = "organization.invites"
	EndpointAccept       auth.EndpointID = "organization.accept"
	EndpointRemoveMember auth.EndpointID = "organization.remove-member"
	EndpointSetRole      auth.EndpointID = "organization.set-role"
	EndpointCheckSlug    auth.EndpointID = "organization.check-slug"
	EndpointInvitation   auth.EndpointID = "organization.invitation"
	EndpointRole         auth.EndpointID = "organization.role"

	EndpointGet             auth.EndpointID = "organization.get"
	EndpointGetFull         auth.EndpointID = "organization.get-full"
	EndpointUpdate          auth.EndpointID = "organization.update"
	EndpointDelete          auth.EndpointID = "organization.delete"
	EndpointSetActive       auth.EndpointID = "organization.set-active"
	EndpointActive          auth.EndpointID = "organization.active"
	EndpointReject          auth.EndpointID = "organization.reject"
	EndpointCancel          auth.EndpointID = "organization.cancel"
	EndpointListInvitations auth.EndpointID = "organization.list-invitations"
	EndpointHasPermission   auth.EndpointID = "organization.has-permission"

	EndpointCreateTeam       auth.EndpointID = "organization.create-team"
	EndpointListTeams        auth.EndpointID = "organization.list-teams"
	EndpointUpdateTeam       auth.EndpointID = "organization.update-team"
	EndpointRemoveTeam       auth.EndpointID = "organization.remove-team"
	EndpointSetActiveTeam    auth.EndpointID = "organization.set-active-team"
	EndpointListUserTeams    auth.EndpointID = "organization.list-user-teams"
	EndpointListTeamMembers  auth.EndpointID = "organization.list-team-members"
	EndpointAddTeamMember    auth.EndpointID = "organization.add-team-member"
	EndpointRemoveTeamMember auth.EndpointID = "organization.remove-team-member"

	EndpointCreateRole auth.EndpointID = "organization.create-role"
	EndpointUpdateRole auth.EndpointID = "organization.update-role"
	EndpointDeleteRole auth.EndpointID = "organization.delete-role"
	EndpointGetRole    auth.EndpointID = "organization.get-role"
	EndpointListRoles  auth.EndpointID = "organization.list-roles"
)

Operation ids. They are permanent: rate rules, hooks and every transport route address an operation by id, so renaming one breaks deployed clients.

View Source
const (
	// DefaultInviteLimit and DefaultInviteWindow bound invitation mail per
	// client address: 50 an hour lets an admin onboard a team in one sitting
	// and keeps a stolen session from turning the app's mail domain into a
	// spam relay. InviteLimit changes both.
	//ds:def id=invitelimit-6wd9gmj5 owner=@khanakia stability=stable
	DefaultInviteLimit = 50
	//ds:def id=invitewindow-fhrf25ap owner=@khanakia stability=stable
	DefaultInviteWindow = time.Hour

	// DefaultMembersPage and MaxMembersPage bound the members list: a page
	// an org settings screen shows, and a cap so one request cannot read a
	// huge organization at once. A larger limit is clamped, not refused.
	DefaultMembersPage = 50
	MaxMembersPage     = 200

	// DefaultInvitationLimit is better-auth's invitationLimit: at most this
	// many pending invitations per organization, so a stolen admin session
	// cannot fill the table. InvitationLimit changes it; zero is no cap.
	DefaultInvitationLimit = 100
)
View Source
const (
	ResourceOrganization = "organization"
	ResourceMember       = "member"
	ResourceInvitation   = "invitation"
	ResourceTeam         = "team"
	// ResourceAC is access control itself: the dynamic roles.
	ResourceAC = "ac"
)

Resources of DefaultStatements, the ones the plugin's own operations check.

View Source
const (
	ActionCreate = "create"
	ActionRead   = "read"
	ActionUpdate = "update"
	ActionDelete = "delete"
	ActionCancel = "cancel"
)

Actions of DefaultStatements.

View Source
const DefaultInvitePage = "/invitations"

DefaultInvitePage is the app page an invitation mail links to; it lists the signed-in user's invitations through organization.invites.

View Source
const DefaultInviteTTL = 7 * 24 * time.Hour

DefaultInviteTTL is how long an invitation stays acceptable: a week covers an invitee who is away for a few days, and anything longer leaves a forgotten invitation usable by whoever controls that inbox months later.

View Source
const MaxMetadataSize = 16 << 10

MaxMetadataSize bounds an organization's metadata, in bytes: room for settings and identifiers, not for documents, which belong in the app's own tables keyed by the organization id.

View Source
const PluginName = "organization"

PluginName is the stable identity of this plugin.

View Source
const PurposeInvite auth.Purpose = "organization.invite"

PurposeInvite is the message sent to an invited address. A mailer switches on it to pick the invitation template.

Variables

Domain tables and typed columns, for an app that joins them; reads are open, writes go through the operations.

View Source
var (
	// ErrDomainNotFound is an unknown domain id, or one of an organization
	// the caller is not in; one error for both, so a guessed id says nothing.
	ErrDomainNotFound = auth.NewError("organization.domain_not_found", http.StatusNotFound, "Not found.")
	// ErrBadDomain is a value that is not a domain name.
	ErrBadDomain = auth.NewError("organization.bad_domain", http.StatusBadRequest, "Enter a domain such as example.com.")
	// ErrPublicDomain is a domain anyone can get an address at (gmail.com),
	// which would let every stranger with such an address enroll.
	ErrPublicDomain = auth.NewError("organization.public_domain", http.StatusBadRequest, "This is a public email domain and cannot belong to an organization.")
	// ErrDomainTaken is a domain another organization has verified, or one
	// this organization has already added.
	ErrDomainTaken = auth.NewError("organization.domain_taken", http.StatusConflict, "This domain is already in use.")
	// ErrDomainsLimit is an organization with MaxDomains domains.
	ErrDomainsLimit = auth.NewError("organization.domains_limit", http.StatusConflict, "This organization has reached the number of domains it can add.")
	// ErrDomainNotVerified answers verify-domain when the record is not
	// found yet, and an enrollment mode set on a domain not verified.
	ErrDomainNotVerified = auth.NewError("organization.domain_not_verified", http.StatusConflict, "The domain is not verified yet. Publish the TXT record and try again.")
	// ErrDomainCheckFailed is a verifier that failed (a DNS outage), as
	// distinct from one that looked and found no record.
	ErrDomainCheckFailed = auth.NewError("organization.domain_check_failed", http.StatusServiceUnavailable, "The domain could not be checked. Try again later.")
	// ErrBadEnrollment is an EnrollmentMode outside EnrollmentModeValues.
	ErrBadEnrollment = auth.NewError("organization.bad_enrollment", http.StatusBadRequest, "This enrollment mode does not exist.")
	// ErrDomainRole is the creator role given to a domain: nobody becomes an
	// owner because of the address they sign up with.
	ErrDomainRole = auth.NewError("organization.domain_role", http.StatusBadRequest, "A domain cannot grant this role.")
	// ErrRequestInvalid is a membership request or suggestion that is
	// unknown, not the caller's to act on, decided already, or whose domain
	// or address is no longer verified.
	ErrRequestInvalid = auth.NewError("organization.request_invalid", http.StatusNotFound, "This request is not valid any more.")
)

Errors of verified domains.

View Source
var (
	TableOrg    = sqlb.Table{Name: TableNameOrg, Alias: TableAliasOrg}
	TableMember = sqlb.Table{Name: TableNameMember, Alias: TableAliasMember}
	TableInvite = sqlb.Table{Name: TableNameInvite, Alias: TableAliasInvite}

	TableTeam       = sqlb.Table{Name: TableNameTeam, Alias: TableAliasTeam}
	TableTeamMember = sqlb.Table{Name: TableNameTeamMember, Alias: TableAliasTeamMember}
	TableRole       = sqlb.Table{Name: TableNameRole, Alias: TableAliasRole}
	TableActive     = sqlb.Table{Name: TableNameActive, Alias: TableAliasActive}

	// TableIdentity is the core identity table under this plugin's alias, used
	// to match an invitation against a verified address.
	TableIdentity = sqlb.Table{Name: auth.TableNameIdentity, Alias: TableAliasIdentity}
)

Tables this plugin declares.

View Source
var (
	OrgID        = sqlb.Col[string](TableOrg, ColOrgID)
	OrgSlug      = sqlb.Col[string](TableOrg, ColOrgSlug)
	OrgName      = sqlb.Col[string](TableOrg, ColOrgName)
	OrgCreatedAt = sqlb.Col[time.Time](TableOrg, ColOrgCreatedAt)
	OrgMetadata  = sqlb.Col[auth.AppData](TableOrg, ColOrgMetadata)

	MemberOrgID    = sqlb.Col[string](TableMember, ColMemberOrgID)
	MemberUserID   = sqlb.Col[string](TableMember, ColMemberUserID)
	MemberRole     = sqlb.Col[Role](TableMember, ColMemberRole)
	MemberJoinedAt = sqlb.Col[time.Time](TableMember, ColMemberJoinedAt)

	InviteID        = sqlb.Col[string](TableInvite, ColInviteID)
	InviteOrgID     = sqlb.Col[string](TableInvite, ColInviteOrgID)
	InviteEmail     = sqlb.Col[string](TableInvite, ColInviteEmail)
	InviteKind      = sqlb.Col[auth.IdentifierKindName](TableInvite, ColInviteKind)
	InviteIdent     = sqlb.Col[string](TableInvite, ColInviteIdentifier)
	InviteRole      = sqlb.Col[Role](TableInvite, ColInviteRole)
	InviteInvitedBy = sqlb.Col[string](TableInvite, ColInviteInvitedBy)
	InviteExpiresAt = sqlb.Col[time.Time](TableInvite, ColInviteExpiresAt)
	InviteAccepted  = sqlb.Col[*time.Time](TableInvite, ColInviteAccepted)
	InviteCreatedAt = sqlb.Col[time.Time](TableInvite, ColInviteCreatedAt)
	InviteStatusCol = sqlb.Col[InviteStatus](TableInvite, ColInviteStatus)
	InviteTeamID    = sqlb.Col[*string](TableInvite, ColInviteTeamID)

	TeamID        = sqlb.Col[string](TableTeam, ColTeamID)
	TeamOrgID     = sqlb.Col[string](TableTeam, ColTeamOrgID)
	TeamName      = sqlb.Col[string](TableTeam, ColTeamName)
	TeamCreatedAt = sqlb.Col[time.Time](TableTeam, ColTeamCreatedAt)
	TeamUpdatedAt = sqlb.Col[time.Time](TableTeam, ColTeamUpdatedAt)

	TeamMemberTeamID    = sqlb.Col[string](TableTeamMember, ColTeamMemberTeamID)
	TeamMemberUserID    = sqlb.Col[string](TableTeamMember, ColTeamMemberUserID)
	TeamMemberCreatedAt = sqlb.Col[time.Time](TableTeamMember, ColTeamMemberCreatedAt)

	RoleID         = sqlb.Col[string](TableRole, ColRoleID)
	RoleOrgID      = sqlb.Col[string](TableRole, ColRoleOrgID)
	RoleName       = sqlb.Col[Role](TableRole, ColRoleName)
	RolePermission = sqlb.Col[PermissionDoc](TableRole, ColRolePermission)
	RoleCreatedAt  = sqlb.Col[time.Time](TableRole, ColRoleCreatedAt)
	RoleUpdatedAt  = sqlb.Col[time.Time](TableRole, ColRoleUpdatedAt)

	ActiveSessionID = sqlb.Col[string](TableActive, ColActiveSessionID)
	ActiveUserID    = sqlb.Col[string](TableActive, ColActiveUserID)
	ActiveOrgID     = sqlb.Col[*string](TableActive, ColActiveOrgID)
	ActiveTeamID    = sqlb.Col[*string](TableActive, ColActiveTeamID)
	ActiveExpiresAt = sqlb.Col[time.Time](TableActive, ColActiveExpiresAt)

	IdentityUserID     = sqlb.Col[string](TableIdentity, auth.ColIdentityUserID)
	IdentityKind       = sqlb.Col[auth.IdentifierKindName](TableIdentity, auth.ColIdentityKind)
	IdentityValue      = sqlb.Col[string](TableIdentity, auth.ColIdentityValue)
	IdentityVerifiedAt = sqlb.Col[*time.Time](TableIdentity, auth.ColIdentityVerifiedAt)
)

Typed columns, for an app that joins this plugin's tables into its own queries; reads are open, writes go through the operations.

View Source
var (
	// ErrNotMember answers a non-member with 404, never 403, so a stranger
	// cannot tell an organization they may not see from one that does not
	// exist.
	ErrNotMember = auth.NewError("organization.not_member", http.StatusNotFound, "Not found.")
	// ErrForbidden is a member whose role is too low for the request.
	ErrForbidden = auth.NewError("organization.forbidden", http.StatusForbidden, "Your role does not allow this.")
	// ErrSlugTaken is a slug another organization already uses.
	ErrSlugTaken = auth.NewError("organization.slug_taken", http.StatusConflict, "This address is already in use.")
	// ErrBadSlug is a slug that does not match SlugPattern.
	ErrBadSlug = auth.NewError("organization.bad_slug", http.StatusBadRequest, "Use lowercase letters, digits and dashes.")
	// ErrBadRole is a role outside RoleValues.
	ErrBadRole = auth.NewError("organization.bad_role", http.StatusBadRequest, "This role does not exist.")
	// ErrLastOwner refuses to remove or demote the only owner, which would
	// leave an organization nobody can administer.
	ErrLastOwner = auth.NewError("organization.last_owner", http.StatusConflict, "An organization needs at least one owner.")
	// ErrInviteInvalid is an invitation that is unknown, expired, already
	// accepted, or addressed to an address the caller has not verified. One
	// error for all four, so a guessed id reveals nothing.
	ErrInviteInvalid = auth.NewError("organization.invite_invalid", http.StatusNotFound, "This invitation is not valid any more.")
	// ErrCreateForbidden is a user the CreateGate refused.
	ErrCreateForbidden = auth.NewError("organization.create_forbidden", http.StatusForbidden, "You cannot create an organization.")
	// ErrLimitReached is a user who already owns MaxOwned organizations.
	ErrLimitReached = auth.NewError("organization.limit_reached", http.StatusConflict, "You have reached the number of organizations you can own.")
	// ErrMembersLimit is an organization that already has MaxMembers members.
	ErrMembersLimit = auth.NewError("organization.members_limit", http.StatusConflict, "This organization is full.")
	// ErrNoActiveOrg is a request that named no organization while its
	// session has none active.
	ErrNoActiveOrg = auth.NewError("organization.no_active", http.StatusBadRequest, "No organization is active.")
	// ErrAlreadyMember refuses to invite or add a member, with
	// RejectExistingMembers.
	ErrAlreadyMember = auth.NewError("organization.already_member", http.StatusConflict, "This user is already a member.")
	// ErrInvitationLimit is an organization with InvitationLimit pending
	// invitations already.
	ErrInvitationLimit = auth.NewError("organization.invitation_limit", http.StatusConflict, "This organization has too many pending invitations.")
	// ErrBadMetadata is metadata that is not a JSON object or exceeds
	// MaxMetadataSize.
	ErrBadMetadata = auth.NewError("organization.bad_metadata", http.StatusBadRequest, "Metadata must be a JSON object of at most 16 KiB.")
	// ErrTeamNotFound is a team that does not exist or that the caller may
	// not see; one answer for both, like ErrNotMember.
	ErrTeamNotFound = auth.NewError("organization.team_not_found", http.StatusNotFound, "Not found.")
	// ErrTeamsLimit is an organization with TeamsConfig.MaxTeams teams.
	ErrTeamsLimit = auth.NewError("organization.teams_limit", http.StatusConflict, "This organization has reached its number of teams.")
	// ErrTeamMembersLimit is a team with TeamsConfig.MaxMembersPerTeam
	// members.
	ErrTeamMembersLimit = auth.NewError("organization.team_members_limit", http.StatusConflict, "This team is full.")
	// ErrLastTeam refuses to remove an organization's only team, unless
	// TeamsConfig.AllowRemovingAllTeams.
	ErrLastTeam = auth.NewError("organization.last_team", http.StatusConflict, "An organization needs at least one team.")
	// ErrRoleNotFound is a dynamic role that does not exist in the
	// organization.
	ErrRoleNotFound = auth.NewError("organization.role_not_found", http.StatusNotFound, "This role does not exist.")
	// ErrRoleTaken is a role name the organization, or the static roles,
	// already use.
	ErrRoleTaken = auth.NewError("organization.role_taken", http.StatusConflict, "A role with this name already exists.")
	// ErrRolesLimit is an organization with DynamicRolesConfig.MaxRoles
	// roles.
	ErrRolesLimit = auth.NewError("organization.roles_limit", http.StatusConflict, "This organization has reached its number of roles.")
	// ErrRoleInUse refuses to delete a role members still hold.
	ErrRoleInUse = auth.NewError("organization.role_in_use", http.StatusConflict, "Members still hold this role.")
	// ErrBadPermission is a permission outside the access-control
	// statements, or one the caller does not hold and so cannot grant.
	ErrBadPermission = auth.NewError("organization.bad_permission", http.StatusBadRequest, "This permission cannot be granted.")
	// ErrPredefinedRole answers delete-role or update-role naming a role
	// fixed in configuration (owner, admin, member, or one of
	// WithRoles'), which only a deploy changes.
	ErrPredefinedRole = auth.NewError("organization.predefined_role", http.StatusBadRequest, "This role is part of the configuration and cannot be changed here.")
	// ErrNoActiveTeam answers list-team-members with no teamId when the
	// session has no active team.
	ErrNoActiveTeam = auth.NewError("organization.no_active_team", http.StatusBadRequest, "No team is active.")
	// ErrAlreadyInvited answers invite, with RefuseRepeatInvites, for an
	// address that already holds a pending invitation to the organization.
	// The caller holds invitation:create there, so it reveals nothing a
	// list-invitations does not.
	ErrAlreadyInvited = auth.NewError("organization.already_invited", http.StatusConflict, "This address already has a pending invitation.")
	// ErrInviteAddress is an invitee or member named two ways at once
	// (Email and an Identifier), or by a kind InviteBy does not allow.
	ErrInviteAddress = auth.NewError("organization.invite_address", http.StatusBadRequest, "Name the person by one address of a kind this app invites by.")
)

Errors a client may see. Each carries a stable code for programmatic checks.

View Source
var DefaultAccessControl = access.MustNew(DefaultStatements)

DefaultAccessControl is the access control of DefaultStatements.

View Source
var DefaultPublicDomains = []string{
	"gmail.com", "googlemail.com", "yahoo.com", "yahoo.co.uk", "yahoo.co.in", "ymail.com", "rocketmail.com",
	"outlook.com", "hotmail.com", "hotmail.co.uk", "live.com", "msn.com", "icloud.com", "me.com", "mac.com",
	"aol.com", "proton.me", "protonmail.com", "pm.me", "gmx.com", "gmx.de", "gmx.net", "web.de", "mail.com",
	"yandex.com", "yandex.ru", "mail.ru", "zoho.com", "qq.com", "163.com", "126.com", "fastmail.com",
	"hey.com", "tutanota.com", "tuta.io", "rediffmail.com", "naver.com", "hanmail.net", "libero.it", "orange.fr",
}

DefaultPublicDomains are the free mail providers IsDefaultPublicDomain refuses. It is a starting list, not a complete one: an app that serves a region with other providers passes its own DomainsConfig.IsPublicDomain.

DefaultStatements is better-auth's organization vocabulary: what each operation of the plugin checks. An app adding resources of its own builds its access control from a copy of it (WithAccessControl).

EnrollmentModeValues is every EnrollmentMode.

View Source
var ErrBadQuery = auth.NewError("organization.bad_query", http.StatusBadRequest, "This sort or filter is not supported.")

ErrBadQuery is a sort key, direction, filter field or operator the members list does not support.

View Source
var ErrNoAuthorizer = errors.New("organization: WithAuthorizer needs an authorizer")

ErrNoAuthorizer was WithAuthorizer(nil).

Deprecated: no longer returned. WithAuthorizer(nil) fails auth.New with auth.ErrNilOption naming the option, as every option given nil does.

View Source
var FieldNamePattern = auth.KindNamePattern

FieldNamePattern is what an extra column may be named: the core's rule for column extensions, so a name is a plain identifier on every engine.

FilterOperatorValues is the canonical list of filter operators.

InviteStatusValues is the canonical list of invitation states.

MemberFieldValues is the canonical list of filter fields.

MemberSortValues is the canonical list of sort keys.

RoleValues is the canonical iteration order, from most to least privileged. Seeds, permission checks and any role selector read from this one list.

View Source
var SlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,47}$`)

SlugPattern is what an organization slug may look like: 2 to 48 lowercase letters, digits and dashes, not starting with a dash. It appears in URLs and subdomains, so it is restricted to what needs no escaping in either, and 48 keeps it inside a 63-character DNS label with room for an app's prefix.

Functions

func DefaultRoles

func DefaultRoles() map[Role]access.Role

DefaultRoles returns better-auth's three roles: the owner may do everything; the admin everything but delete the organization; the member only read the roles. A new map each call, so an app can change it.

func EmailDomain

func EmailDomain(addr string) string

EmailDomain returns the domain of a normalized email address, the part after its last "@", in NormalizeDomain's form; "" when it has none.

func IsDefaultPublicDomain

func IsDefaultPublicDomain(domain string) bool

IsDefaultPublicDomain reports whether domain, normalized, is in DefaultPublicDomains.

func MembersPageQuery

func MembersPageQuery(a *auth.Auth, orgID string, dst *Member, limit, offset int) (sqlb.Query, error)

MembersPageQuery is MembersQuery with a page: limit rows from offset, in the same joined-at order, so the pages of one listing never overlap.

func MembersQuery

func MembersQuery(a *auth.Auth, orgID string, dst *Member) (sqlb.Query, error)

MembersQuery lists an organization's members with their core user fields.

This is the canonical plugin-joins-core statement: the membership rows are this plugin's, the email and name are the core's, and both are typed because the plugin imports the core and uses its exported columns rather than hardcoding names.

func MembershipQuery

func MembershipQuery(a *auth.Auth, orgID, userID string, dst *Role) (sqlb.Query, error)

MembershipQuery resolves one user's role in one organization.

Used by an authorization check, so it returns the role rather than a boolean: the caller compares with Role.AtLeast, which keeps the privilege ordering in one place instead of spreading a chain of equality tests across handlers.

func NormalizeDomain

func NormalizeDomain(raw string) (string, error)

NormalizeDomain returns the stored form of a domain: trimmed, lowercased, without a leading "@" or a trailing dot. It refuses anything that is not a name of two or more labels with a non-numeric top label, so an IP address or a bare TLD never becomes a domain. Unicode names are given in punycode.

func PendingInvitesQuery

func PendingInvitesQuery(a *auth.Auth, userID string, now time.Time, dst *Invite) (sqlb.Query, error)

PendingInvitesQuery lists the invitations a given user may accept.

Every predicate here is load-bearing and one of them is the whole reason this function exists rather than being written inline at the call site:

The join to auth_identity requires verified_at IS NOT NULL. Without it, a user who has merely TYPED the victim's address into their account settings — and never clicked anything in the victim's inbox — sees and can accept the victim's invitation. That is one missing clause and it is a full workspace takeover. It is also precisely the published CVE in at least one comparable library, which is why the check lives in this one exported function rather than at each call site.

It is the same rule as auth.VerifiedIdentity, written out rather than called: that predicate is bound to the core's alias for auth_identity, and this statement joins the table under the plugin's own alias (TableIdentity, "oid") so it cannot collide with a core join in a composite query. Calling the core predicate here would reference an alias the statement does not have. TestPendingInvitesQuery_RequiresVerifiedIdentity pins the rendered clause.

The address match is exact, on kind and value both (InviteBy): an invitation to a phone number is matched only by a verified phone identity with that number. An identity value is stored normalized, so an invitation's identifier is stored normalized too, through its kind's normalizer at the moment the invitation is written; an exact comparison of two normal forms then needs no lower() in SQL, which would be a second folding rule that differs between engines.

The remaining predicates exclude accepted, rejected, canceled and expired invitations, all of which would otherwise be replayable indefinitely.

func UserBindings

func UserBindings(dst *UserMembership) []sqlb.Binding

UserBindings binds this plugin's composite columns to dst.

Role is coalesced because the LEFT join yields null for a non-member and Role is a named string type that cannot hold it. JoinedAt is bound to a pointer instead, because nil is the honest representation of "never joined" and any coalesced timestamp would be a fabricated date.

func UserJoin

func UserJoin(orgID string) sqlb.Join

UserJoin brings this user's membership of one specific organization into a composite user query.

Why it takes an argument when the other plugins' joins do not: a user has one phone number but a membership only exists relative to an organization, so the join must be parameterised. The org id is BOUND inside the ON clause rather than interpolated, which is why the resulting statement carries an extra placeholder before the WHERE clause even begins.

LEFT, so a user who is not a member still comes back rather than vanishing from a query that was only asking who they are.

Types

type AcceptRequest

type AcceptRequest struct {
	InviteID string `json:"inviteId"`
}

AcceptRequest names an invitation, as the input of accept, reject, cancel and invitation.

type AccessRequest

type AccessRequest struct {
	// OrgID is the organization the decision is about.
	OrgID string
	// UserID is the member whose permission is decided: the caller, or the
	// user Can names.
	UserID string
	// Role is UserID's role in OrgID (Role.Parts for several). A model that
	// does not use roles ignores it.
	Role Role
	// Request is what is asked for, per resource.
	Request access.Request
	// Connector joins the resources; access.And when empty.
	Connector access.Connector
	// Operation is the operation asking, or "" outside one (Can,
	// RequirePermission, APIKeyPermission).
	Operation auth.EndpointID
	// Call is the operation's call (client, session, decoded input) for an
	// attribute-based rule, or nil outside an operation. Read it; never
	// change it.
	Call *auth.Call
}

AccessRequest is one permission decision. It is a struct so it can carry more context later without breaking an Authorizer.

type ActiveResult

type ActiveResult struct {
	OrgID  *string `json:"activeOrganizationId"`
	TeamID *string `json:"activeTeamId"`
	Org    *Org    `json:"organization,omitempty"`
}

ActiveResult is the session's selection: the active organization and team ids, nil for none, and the organization itself when one is active.

type AddDomainRequest

type AddDomainRequest struct {
	OrgID      string         `json:"orgId,omitempty"`
	Domain     string         `json:"domain"`
	Enrollment EnrollmentMode `json:"enrollmentMode,omitempty"`
	Role       Role           `json:"role,omitempty"`
}

AddDomainRequest is the input of add-domain; an empty OrgID means the active organization. Enrollment is EnrollManual and Role RoleMember when empty.

type AddMemberRequest

type AddMemberRequest struct {
	OrgID  string
	UserID string
	Role   Role
	Fields auth.Fields
}

AddMemberRequest is AddMemberWith's input: AddMember with the member's extra columns (WithFields).

type AllOrgsRequest

type AllOrgsRequest struct {
	Page   int
	Limit  int
	Search string
}

AllOrgsRequest is the input of ListAll. Search keeps organizations whose slug contains it; a slug is lowercase, so the search is lowercased first and matches the same on every engine.

type AllOrgsResult

type AllOrgsResult struct {
	Orgs  []OrgSummary `json:"orgs"`
	Total int64        `json:"total"`
	Page  int          `json:"page"`
	Limit int          `json:"limit"`
}

AllOrgsResult is one page of ListAll, newest first.

type Authorizer

type Authorizer interface {
	Authorize(ctx context.Context, req AccessRequest) (bool, error)
}

Authorizer decides whether an AccessRequest is allowed. Any error refuses: the plugin never reads the boolean of a call that returned an error, so a policy engine that is down fails closed. It must be safe for concurrent use.

type AuthorizerFunc

type AuthorizerFunc func(ctx context.Context, req AccessRequest) (bool, error)

AuthorizerFunc adapts a function to Authorizer.

func (AuthorizerFunc) Authorize

func (f AuthorizerFunc) Authorize(ctx context.Context, req AccessRequest) (bool, error)

Authorize calls f.

type CreateRequest

type CreateRequest struct {
	Name string `json:"name"`
	Slug string `json:"slug"`
	// Logo and Metadata are optional (Org).
	Metadata auth.AppData `json:"metadata,omitempty"`
	// KeepActive leaves the session's active organization as it is; by
	// default the new organization becomes active, as in better-auth
	// (keepCurrentActiveOrganization).
	KeepActive bool `json:"keepCurrentActiveOrganization,omitempty"`
	// Fields set the organization's extra columns (WithFields).
	Fields auth.Fields `json:"fields,omitempty"`
	// contains filtered or unexported fields
}

CreateRequest is the input of create.

func (*CreateRequest) UnmarshalJSON

func (r *CreateRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON keeps top-level field keys for create.

type CreateRoleRequest

type CreateRoleRequest struct {
	OrgID      string             `json:"orgId,omitempty"`
	Role       Role               `json:"role"`
	Permission access.Permissions `json:"permission"`
	Fields     auth.Fields        `json:"fields,omitempty"`
	// contains filtered or unexported fields
}

CreateRoleRequest is the input of create-role; an empty OrgID means the active organization.

func (CreateRoleRequest) AuditOrgID

func (r CreateRoleRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for create-role.

func (*CreateRoleRequest) UnmarshalJSON

func (r *CreateRoleRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON keeps top-level field keys for create-role.

type CreateTeamRequest

type CreateTeamRequest struct {
	OrgID  string      `json:"orgId,omitempty"`
	Name   string      `json:"name"`
	Fields auth.Fields `json:"fields,omitempty"`
	// contains filtered or unexported fields
}

CreateTeamRequest is the input of create-team; an empty OrgID means the active organization.

func (CreateTeamRequest) AuditOrgID

func (r CreateTeamRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for create-team.

func (*CreateTeamRequest) UnmarshalJSON

func (r *CreateTeamRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON keeps top-level field keys for create-team.

type DNSVerifier

type DNSVerifier struct {
	Resolver TXTResolver
}

DNSVerifier is the default DomainVerifier: the challenge's TXT record must hold its value exactly. Resolver is net.DefaultResolver when nil.

func (DNSVerifier) VerifyDomain

func (v DNSVerifier) VerifyDomain(ctx context.Context, ch DomainChallenge) (bool, error)

VerifyDomain looks the record up. A name that does not exist is "not yet", not an error, since that is the state before the admin publishes it.

type Direction

type Direction string

Direction is a sort direction.

const (
	Asc  Direction = "asc"
	Desc Direction = "desc"
)

Directions.

type DomainChallenge

type DomainChallenge struct {
	Domain      string
	RecordName  string
	RecordValue string
}

DomainChallenge is what a DomainVerifier checks: that RecordName has a TXT record whose value is RecordValue.

type DomainRequest

type DomainRequest struct {
	DomainID string `json:"domainId"`
}

DomainRequest names a domain, as the input of verify-domain, remove-domain and accept-suggestion.

type DomainSuggestion

type DomainSuggestion struct {
	DomainID string `json:"domainId"`
	OrgID    string `json:"orgId"`
	OrgName  string `json:"orgName"`
	OrgSlug  string `json:"orgSlug"`
	Domain   string `json:"domain"`
	// Requested is true once the caller asked to join and an admin has not
	// decided yet.
	Requested bool `json:"requested"`
}

DomainSuggestion is an organization the caller may ask to join, because it verified the domain of one of the caller's verified emails.

type DomainVerifier

type DomainVerifier interface {
	VerifyDomain(ctx context.Context, ch DomainChallenge) (bool, error)
}

DomainVerifier decides whether an organization controls a domain. The default is DNSVerifier; an app replaces it (DomainsConfig.Verifier) to check an HTTP file, ask its registrar's API, or use a fake in tests. It answers false with no error when the proof is not there yet, and an error only when it could not look. ds:def id=domainverifier-xyngwy9v owner=@khanakia stability=api

type DomainVerifierFunc

type DomainVerifierFunc func(ctx context.Context, ch DomainChallenge) (bool, error)

DomainVerifierFunc adapts a function to DomainVerifier.

func (DomainVerifierFunc) VerifyDomain

func (f DomainVerifierFunc) VerifyDomain(ctx context.Context, ch DomainChallenge) (bool, error)

VerifyDomain calls f.

type DomainsConfig

type DomainsConfig struct {
	// Verifier proves ownership; DNSVerifier{} when nil.
	Verifier DomainVerifier
	// RecordPrefix is the TXT record's label, DefaultDomainRecordPrefix when
	// empty. It must start with an underscore.
	RecordPrefix string
	// TrustVerifiedEmail verifies a domain without DNS when the admin who
	// adds or verifies it holds a verified email at exactly that domain.
	// Off by default: it lets any employee with a work address claim the
	// company's domain for an organization they created, which DNS does not.
	TrustVerifiedEmail bool
	// IsPublicDomain reports a domain that no organization may add, because
	// anyone can get an address there; IsDefaultPublicDomain when nil.
	IsPublicDomain func(domain string) bool
	// MaxDomains caps the domains of one organization; DefaultMaxDomains
	// when zero. Negative is refused.
	MaxDomains int
}

DomainsConfig configures verified domains (Domains). Every field is optional.

type DomainsRequest

type DomainsRequest struct {
	OrgID string `json:"orgId,omitempty"`
}

DomainsRequest names an organization, as the input of list-domains and list-membership-requests; empty means the active one.

type DomainsResult

type DomainsResult struct {
	Domains []OrgDomain `json:"domains"`
}

DomainsResult is the output of list-domains.

type DynamicRolesConfig

type DynamicRolesConfig struct {
	// MaxRoles caps the dynamic roles of one organization
	// (maximumRolesPerOrganization); zero is no cap.
	MaxRoles int
}

DynamicRolesConfig configures roles created at run time (DynamicRoles), better-auth's dynamicAccessControl.

type EnrollmentMode

type EnrollmentMode string

EnrollmentMode is what a verified domain does for a user whose email at the domain is verified. The names are Clerk's, plus automatic_join. ds:def id=enrollmentmode-3c3jpwcb owner=@khanakia stability=stable

const (
	// EnrollManual does nothing automatically: members are invited by hand.
	// The default, so adding a domain changes nobody's access by itself.
	EnrollManual EnrollmentMode = "manual_invitation"
	// EnrollInvitation creates a pending invitation (organization.invites),
	// which the user accepts as any other.
	EnrollInvitation EnrollmentMode = "automatic_invitation"
	// EnrollSuggestion lists the organization among the user's
	// domain-suggestions; accepting one asks to join, and an admin approves.
	EnrollSuggestion EnrollmentMode = "automatic_suggestion"
	// EnrollJoin makes the user a member with the domain's role.
	EnrollJoin EnrollmentMode = "automatic_join"
)

Enrollment modes.

type Field

type Field struct {
	// Name is the column name; it must match FieldNamePattern and must not
	// be one of the table's own columns.
	Name string
	// Type is one of auth.ColumnTypeValues; a value is decoded as the core
	// decodes sign-up fields (auth.Fields): a JSON string for text, an
	// integer for int, true or false for bool, an RFC 3339 string for
	// timestamp, null for a nullable column.
	Type     auth.ColumnType
	Nullable bool
	// Default is a constant SQL literal ('x', 42, true, false) or empty. A
	// NOT NULL field needs one, so existing rows can be filled.
	Default string
	// Required refuses a create that does not give the field
	// (better-auth's required: true).
	Required bool
	// ServerOnly refuses the field in a request from a client: only Go
	// calls and hooks set it (better-auth's input: false). For a value the
	// app decides, such as a plan or an external id.
	ServerOnly bool
}

Field is one extra column on one of the plugin's tables.

type FieldsConfig

type FieldsConfig struct {
	Org, Member, Invitation, Team, Role []Field
}

FieldsConfig lists the extra columns per table (WithFields). Team fields need Teams and role fields DynamicRoles; Validate refuses them otherwise.

type FilterOperator

type FilterOperator string

FilterOperator is how a members filter compares.

const (
	OpEq         FilterOperator = "eq"
	OpNe         FilterOperator = "ne"
	OpIn         FilterOperator = "in"
	OpNotIn      FilterOperator = "not_in"
	OpContains   FilterOperator = "contains"
	OpStartsWith FilterOperator = "starts_with"
	OpEndsWith   FilterOperator = "ends_with"
	OpLt         FilterOperator = "lt"
	OpLte        FilterOperator = "lte"
	OpGt         FilterOperator = "gt"
	OpGte        FilterOperator = "gte"
)

Filter operators, better-auth's names. A text field (role, userId, name) takes eq, ne, in, not_in, contains, starts_with and ends_with, all case-sensitive on every engine (memberTextMatch); the time field takes eq, ne, lt, lte, gt and gte. The order operators are refused on text, because "less than" between two names depends on the engine's collation, so the same request would answer differently on Postgres and SQLite.

type FullOrg

type FullOrg struct {
	Org
	Members     []MemberView `json:"members"`
	Invitations []Invitation `json:"invitations"`
	Teams       []Team       `json:"teams,omitempty"`
}

FullOrg is an organization with its members, its pending invitations and, with Teams, its teams: one read for a settings page.

type GetRequest

type GetRequest struct {
	OrgID        string `json:"orgId,omitempty"`
	OrgSlug      string `json:"orgSlug,omitempty"`
	MembersLimit int    `json:"membersLimit,omitempty,string"`
}

GetRequest names an organization by id or slug, the active one when both are empty, as the input of get-organization and get-full-organization. MembersLimit bounds the members of the full read (DefaultMembersPage, at most MaxMembersPage).

func (GetRequest) AuditOrgID

func (r GetRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for get and get-full.

type Hooks

type Hooks struct {
	BeforeCreateOrg func(ctx context.Context, org *Org, creator auth.User) error
	AfterCreateOrg  func(ctx context.Context, org Org, creator auth.User) error
	BeforeUpdateOrg func(ctx context.Context, old Org, org *Org) error
	AfterUpdateOrg  func(ctx context.Context, org Org) error
	BeforeDeleteOrg func(ctx context.Context, org Org) error
	AfterDeleteOrg  func(ctx context.Context, org Org) error

	// BeforeAddMember runs when someone joins (an accepted invitation,
	// AddMember), not for the creator, who joins in BeforeCreateOrg's
	// operation.
	BeforeAddMember        func(ctx context.Context, m *MemberChange) error
	AfterAddMember         func(ctx context.Context, m MemberChange) error
	BeforeRemoveMember     func(ctx context.Context, m MemberChange) error
	AfterRemoveMember      func(ctx context.Context, m MemberChange) error
	BeforeUpdateMemberRole func(ctx context.Context, m *MemberChange, old Role) error
	AfterUpdateMemberRole  func(ctx context.Context, m MemberChange, old Role) error

	// BeforeCreateInvitation may change the role, team and expiry.
	BeforeCreateInvitation func(ctx context.Context, inv *Invitation, inviter auth.User) error
	AfterCreateInvitation  func(ctx context.Context, inv Invitation, inviter auth.User) error
	BeforeAcceptInvitation func(ctx context.Context, inv Invitation, user auth.User) error
	AfterAcceptInvitation  func(ctx context.Context, inv Invitation, user auth.User) error
	BeforeRejectInvitation func(ctx context.Context, inv Invitation, user auth.User) error
	AfterRejectInvitation  func(ctx context.Context, inv Invitation, user auth.User) error
	BeforeCancelInvitation func(ctx context.Context, inv Invitation, by auth.User) error
	AfterCancelInvitation  func(ctx context.Context, inv Invitation, by auth.User) error

	BeforeCreateTeam       func(ctx context.Context, team *Team) error
	AfterCreateTeam        func(ctx context.Context, team Team) error
	BeforeUpdateTeam       func(ctx context.Context, old Team, team *Team) error
	AfterUpdateTeam        func(ctx context.Context, team Team) error
	BeforeDeleteTeam       func(ctx context.Context, team Team) error
	AfterDeleteTeam        func(ctx context.Context, team Team) error
	BeforeAddTeamMember    func(ctx context.Context, m TeamMemberChange) error
	AfterAddTeamMember     func(ctx context.Context, m TeamMemberChange) error
	BeforeRemoveTeamMember func(ctx context.Context, m TeamMemberChange) error
	AfterRemoveTeamMember  func(ctx context.Context, m TeamMemberChange) error
}

Hooks are the organization lifecycle hooks, better-auth's organizationHooks: every one is optional and runs inside the operation's transaction. A Before hook may change the row it is given (a pointer) or refuse with an error, which becomes the operation's error; an After hook's error fails the operation and rolls everything back.

What a Before hook changes is checked again: a slug or a role it sets must pass the same rules as the request's, and the fields the checks were made for (ids, the invited address) are restored, so a hook can shape a row but never move it past an authorization check.

A hook writes the app's own rows in the same transaction through the *auth.Auth: a.Conn(ctx) is that transaction, so pass WithHooksFrom, whose builder receives a, rather than WithHooks. The same ctx is the one to pass to any other library call the hook makes, which then joins it too.

auth.OperationHook still works on every operation id; these hooks add the typed row and the power to change it.

type Invitation

type Invitation struct {
	ID    string `json:"id"`
	OrgID string `json:"orgId"`
	// Email is the invited address of an email invitation, "" for another
	// kind; Kind and Identifier address every invitation.
	Email      string                  `json:"email"`
	Kind       auth.IdentifierKindName `json:"kind"`
	Identifier string                  `json:"identifier"`
	Role       Role                    `json:"role"`
	Status     InviteStatus            `json:"status"`
	InviterID  string                  `json:"inviterId"`
	TeamID     *string                 `json:"teamId,omitempty"`
	ExpiresAt  time.Time               `json:"expiresAt"`
	CreatedAt  time.Time               `json:"createdAt"`
	// Fields are the extra columns WithFields declares.
	Fields auth.Fields `json:"fields,omitempty"`
}

Invitation is an invitation row as an organization's members see it: every state, who sent it, and the team it joins.

type InvitationData

type InvitationData struct {
	InvitationID string `json:"invitationId"`
	// Email is the invited address of an email invitation, "" for another
	// kind; Kind and Identifier address every invitation.
	Email            string                  `json:"email"`
	Kind             auth.IdentifierKindName `json:"kind"`
	Identifier       string                  `json:"identifier"`
	Role             Role                    `json:"role"`
	TeamID           *string                 `json:"teamId,omitempty"`
	ExpiresAt        time.Time               `json:"expiresAt"`
	OrganizationID   string                  `json:"organizationId"`
	OrganizationName string                  `json:"organizationName"`
	OrganizationSlug string                  `json:"organizationSlug"`
	InviterID        string                  `json:"inviterId"`
	InviterName      string                  `json:"inviterName"`
	InviterEmail     string                  `json:"inviterEmail,omitempty"`
}

InvitationData is the typed payload of every PurposeInvite message (auth.Message.Data): what better-auth hands its sendInvitationEmail (invitation id, role, invited address, the organization, the inviter), for an app template to read with

d, ok := m.Data.(organization.InvitationData)

instead of parsing the default wording. The link is Message.Link. Through auth.WithDeliveryOutbox the value comes back as this type, restored by the plugin's DecodeMessageData.

InviterEmail is the inviter's primary verified address, "" when they have none: the invitee is told who invited them, as better-auth's template is.

type InvitationsResult

type InvitationsResult struct {
	Invitations []Invitation `json:"invitations"`
}

InvitationsResult is the output of list-invitations.

type Invite

type Invite struct {
	ID      string `json:"id"`
	OrgID   string `json:"orgId"`
	OrgName string `json:"orgName"`
	// Email is the invited address of an email invitation, "" for another
	// kind; Kind and Identifier address every invitation.
	Email      string                  `json:"email"`
	Kind       auth.IdentifierKindName `json:"kind"`
	Identifier string                  `json:"identifier"`
	Role       Role                    `json:"role"`
	ExpiresAt  time.Time               `json:"expiresAt"`
}

Invite is a pending invitation as its invitee sees it: OrgName is joined in so an invitations page needs no second query.

type InviteMail

type InviteMail struct {
	Invite  Invite
	Org     Org
	Inviter auth.User
	// Link is the invitations page, from InviteLink or the base URL.
	Link string
}

InviteMail is what InviteMessage receives.

type InviteRequest

type InviteRequest struct {
	OrgID      string                  `json:"orgId,omitempty"`
	Email      string                  `json:"email,omitempty"`
	Kind       auth.IdentifierKindName `json:"kind,omitempty"`
	Identifier string                  `json:"identifier,omitempty"`
	Role       Role                    `json:"role"`
	TeamID     string                  `json:"teamId,omitempty"`
	// Resend, when the address already holds a pending invitation to the
	// organization, sends that invitation again with a fresh expiry instead
	// of creating another (better-auth's resend). The existing invitation
	// keeps its id, role and team; Role must still be one the caller may
	// invite with. With no pending invitation it is an ordinary invite.
	Resend bool `json:"resend,omitempty"`
	// Fields set the invitation's extra columns (WithFields).
	Fields auth.Fields `json:"fields,omitempty"`
	// contains filtered or unexported fields
}

InviteRequest is the input of invite. The invitee is Email (better-auth's field) or, for any kind InviteBy allows, Kind and Identifier; a Kind left empty is InviteBy's first kind. Naming both Email and an Identifier, or a kind InviteBy does not allow, is ErrInviteAddress. The value is normalized by its kind before it is stored; an empty OrgID means the session's active organization. Role may hold several roles (a JSON array or a comma-joined string), and only a holder of the creator role may invite with it. TeamID, with Teams on, also joins the invitee to that team of the organization on acceptance.

func (InviteRequest) AuditOrgID

func (r InviteRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for invite.

func (*InviteRequest) UnmarshalJSON

func (r *InviteRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON keeps top-level field keys for invite.

type InviteStatus

type InviteStatus string

InviteStatus is where an invitation is in its life. Stored on the row, so an admin's list shows what became of every invitation.

const (
	StatusPending  InviteStatus = "pending"
	StatusAccepted InviteStatus = "accepted"
	StatusRejected InviteStatus = "rejected"
	StatusCanceled InviteStatus = "canceled"
)

Invitation states, as better-auth names them.

type InvitesResult

type InvitesResult struct {
	Invites []Invite `json:"invites"`
}

InvitesResult is the output of invites.

type ListResult

type ListResult struct {
	Orgs []OrgWithRole `json:"orgs"`
}

ListResult is the output of list.

type Member

type Member struct {
	auth.User
	Role     Role
	JoinedAt time.Time
}

Member is a user plus their membership, as MembersQuery scans it. The user is embedded because its columns live on auth_user itself (ADR-0001).

type MemberChange

type MemberChange struct {
	OrgID  string
	UserID string
	Role   Role
	// Fields are the member's extra columns on add; a BeforeAddMember hook
	// may set them, ServerOnly ones included.
	Fields auth.Fields
}

MemberChange is a membership a member hook sees: who, where, and the role they get (add, update) or hold (remove).

type MemberField

type MemberField string

MemberField is what a members filter compares.

const (
	FieldRole      MemberField = "role"
	FieldUserID    MemberField = "userId"
	FieldName      MemberField = "name"
	FieldJoinedAt  MemberField = "joinedAt"
	FieldCreatedAt MemberField = "createdAt"
)

Member filter fields. JoinedAt and CreatedAt are the same column, when the member joined, under our name and better-auth's.

type MemberRequest

type MemberRequest struct {
	OrgID  string `json:"orgId,omitempty"`
	UserID string `json:"userId,omitempty"`
	// Email names the member by a verified address instead of UserID, for a
	// settings page that lists addresses. An address nobody verified is
	// `organization.not_member`, the same as an unknown user id. Kind and
	// Identifier do the same for any identifier kind, such as a verified
	// phone number; naming both Email and an Identifier is ErrInviteAddress.
	Email      string                  `json:"email,omitempty"`
	Kind       auth.IdentifierKindName `json:"kind,omitempty"`
	Identifier string                  `json:"identifier,omitempty"`
}

MemberRequest names a member of an organization, as the input of remove-member.

func (MemberRequest) AuditOrgID

func (r MemberRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for remove-member.

func (MemberRequest) TargetUserID

func (r MemberRequest) TargetUserID() string

TargetUserID names the member a request is about, for plugin/audit's Targeted (an event's subjectId), so the member sees it in their own history.

type MemberSort

type MemberSort string

MemberSort is what the members list is ordered by. A closed set rather than a column name, so a request can never order by, and so probe, a column it should not read.

const (
	// SortJoinedAt orders by when the member joined; the default.
	SortJoinedAt MemberSort = "joinedAt"
	// SortCreatedAt is better-auth's name for the same order.
	SortCreatedAt MemberSort = "createdAt"
	SortName      MemberSort = "name"
	SortRole      MemberSort = "role"
	SortUserID    MemberSort = "userId"
)

Member sort keys.

type MemberView

type MemberView struct {
	User     auth.UserView `json:"user"`
	Role     Role          `json:"role"`
	JoinedAt time.Time     `json:"joinedAt"`
	// Fields are the member's extra columns (WithFields).
	Fields auth.Fields `json:"fields,omitempty"`
}

MemberView is one row of members, with the user reduced to the fields safe to show another member.

type MembersResult

type MembersResult struct {
	Members []MemberView `json:"members"`
	// Total counts every member; Page and Limit are the values used.
	Total int64 `json:"total"`
	Page  int   `json:"page"`
	Limit int   `json:"limit"`
}

MembersResult is the output of members.

type MembershipRequest

type MembershipRequest struct {
	ID        string        `json:"id"`
	OrgID     string        `json:"orgId"`
	DomainID  string        `json:"domainId"`
	Domain    string        `json:"domain"`
	User      auth.UserView `json:"user"`
	Status    RequestStatus `json:"status"`
	CreatedAt time.Time     `json:"createdAt"`
}

MembershipRequest is a user's request to join, made from a suggestion.

type MembershipRequestsResult

type MembershipRequestsResult struct {
	Requests []MembershipRequest `json:"requests"`
}

MembershipRequestsResult is the output of list-membership-requests.

type Option

type Option func(*Plugin)

Option configures the plugin; pass options to New.

func CreateGate

func CreateGate(fn func(ctx context.Context, u auth.User) (bool, error)) Option

CreateGate decides whether a signed-in user may create an organization, for example only on a paid plan. A refusal is `organization.create_forbidden`; an error from fn fails the request. Without it every signed-in user may.

func CreatorRole

func CreatorRole(r Role) Option

CreatorRole sets the role the creator of an organization gets (better-auth's creatorRole). It is also the role the owner protections guard: only its holders may grant it, change or remove a holder, and the last holder cannot leave or be demoted. RoleOwner by default; Validate refuses a role that is not a static role.

func Domains

func Domains(cfg DomainsConfig) Option

Domains turns verified domains on: the domain operations, their tables and the enrollment of users whose verified email is at a verified domain.

func DynamicRoles

func DynamicRoles(cfg DynamicRolesConfig) Option

DynamicRoles lets organizations create roles at run time, each a permission map stored in auth_org_role.

func InvitationLimit

func InvitationLimit(n int) Option

InvitationLimit caps the pending invitations of one organization (better-auth's invitationLimit); one more is `organization.invitation_limit`. DefaultInvitationLimit unless set; zero is no cap. It complements InviteLimit, which bounds mail per client address over time.

func InviteBy

func InviteBy(kinds ...auth.IdentifierKindName) Option

InviteBy sets the identifier kinds an invitation may be addressed to, in place of email alone: InviteBy(phone.KindPhone) for a phone-only app, InviteBy(email.KindEmail, phone.KindPhone) for either. The first kind is the one an InviteRequest naming no Kind uses. Each kind must be registered by an identifier plugin that delivers messages (email and phone do; username does not): auth.New refuses any other kind with auth.ErrNoDeliverer (auth.Config.CanDeliver), rather than the first invite failing. Whatever the kind, an invitation is accepted only by a user holding the VERIFIED identity of that kind and value (PendingInvitesQuery). The email plugin is required only when email is among the kinds.

func InviteLimit

func InviteLimit(limit int, window time.Duration) Option

InviteLimit replaces DefaultInviteLimit and DefaultInviteWindow: at most limit invitations per window from one client address. Both must be positive; Validate refuses anything else.

func InviteLink(fn func() string) Option

InviteLink sets the page an invitation mail links to, for an app whose invitations page is not BaseURL + DefaultInvitePage. It is called per mail, so it may read configuration that changes at run time.

func InviteMessage

func InviteMessage(fn func(m InviteMail) (subject, text string)) Option

InviteMessage words the invitation mail from the invitation, the organization, the inviter and the link, for an app that wants "Alice invited you to Acme" rather than the plugin's generic text. It sets the message's default wording, so an email.Templates case for PurposeInvite still wins.

func MaxMembers

func MaxMembers(n int) Option

MaxMembers caps the members of an organization; an acceptance or AddMember that would exceed it is refused with `organization.members_limit`. Invitations are still sent, because the count is checked when someone joins. Zero, the default, is no cap.

func MaxOwned

func MaxOwned(n int) Option

MaxOwned caps how many organizations one user may own; creating another is refused with `organization.limit_reached`. Counted as memberships holding the creator role, so an owner who was promoted counts and one who left does not. Zero, the default, is no cap.

func RefuseRepeatInvites

func RefuseRepeatInvites() Option

RefuseRepeatInvites makes inviting an address that already holds a pending, unexpired invitation to the organization fail with `organization.already_invited` (better-auth's default), unless the request sets Resend. ReplacePendingInvites wins over it, since it asks for the opposite. Without either, the default, each invite creates another pending invitation.

func RejectExistingMembers

func RejectExistingMembers() Option

RejectExistingMembers refuses to invite an address that already belongs to a member, and AddMember of a member, with `organization.already_member`, as better-auth does. Without it, the default, the invitation is sent and accepting it keeps the member's role.

func ReplacePendingInvites

func ReplacePendingInvites() Option

ReplacePendingInvites makes inviting an address that already has pending invitations to the same organization withdraw them first, so the invitee has one valid invitation and one fresh mail. Without it, the default, each invite creates another pending invitation.

func Teams

func Teams(cfg TeamsConfig) Option

Teams turns teams on: sub-groups inside an organization, with their own members and an active team per session.

func WithAccessControl

func WithAccessControl(ac *access.AccessControl) Option

WithAccessControl replaces DefaultStatements with the app's own vocabulary. The plugin's operations check the resources of DefaultStatements (organization, member, invitation, team, ac), so an app adding its own resources should start from DefaultStatements, as better-auth recommends.

func WithAuthorizer

func WithAuthorizer(a Authorizer) Option

WithAuthorizer replaces the role check as the decider of every organization permission. nil is refused by auth.New. An authorizer that only adds a rule wraps p.RoleAuthorizer(). The creator role's all-permissions rule for API keys belongs to the role model, so with a custom authorizer it decides organization keys too. Roles stay the plugin's for everything that is not a permission: which roles a member may be given, and the owner protections.

func WithFields

func WithFields(cfg FieldsConfig) Option

WithFields declares extra columns on the plugin's tables.

func WithHooks

func WithHooks(h Hooks) Option

WithHooks sets the lifecycle hooks; see Hooks. For hooks that write the app's own rows, which need the *auth.Auth, use WithHooksFrom.

func WithHooksFrom

func WithHooksFrom(build func(a *auth.Auth) Hooks) Option

WithHooksFrom sets the lifecycle hooks from build, which Init calls once with the *auth.Auth: the options are built before auth.New returns it, so this is how a hook gets it without a variable bound after start-up. Every hook runs inside its operation's transaction and ctx carries it, so a hook writes the app's own rows through a.Conn(ctx) or a.Exec(ctx, …) and they commit or roll back with the organization's. Giving WithHooks as well is refused by auth.New.

func WithInviteTTL

func WithInviteTTL(d time.Duration) Option

WithInviteTTL overrides DefaultInviteTTL, for a product whose invitations should lapse sooner or later than a week.

func WithRoles

func WithRoles(roles map[Role]access.Role) Option

WithRoles adds static roles, or replaces a built-in one under the same name, each a role of the access control (WithAccessControl or the default). Validate refuses a role naming a statement the access control does not declare.

Example

ExampleWithRoles adds an app resource and a role of its own beside the built-in ones. ds:def id=examplewithroles-unstje3r owner=@khanakia stability=stable

package main

import (
	"fmt"

	"github.com/ubgo/auth/plugin/access"
	"github.com/ubgo/auth/plugin/organization"
)

func main() {
	s := access.Statements{"project": {"create", "share"}}
	for k, v := range organization.DefaultStatements {
		s[k] = v
	}
	ac := access.MustNew(s)
	billing := ac.MustRole(access.Permissions{"project": {"create"}, organization.ResourceInvitation: {organization.ActionCreate}})
	org := organization.New(organization.WithAccessControl(ac), organization.WithRoles(map[organization.Role]access.Role{"billing": billing}))
	fmt.Println(org.Name(), billing.Allows(access.Request{"project": access.All("create")}))
}
Output:
organization true

func WithoutInvitations

func WithoutInvitations() Option

WithoutInvitations turns the invitation operations off (invite, invites, accept, invitation, reject-invitation, cancel-invitation, list-invitations), for an app that adds members only from the server (AddMember, AddMemberWith). No identifier plugin, invitation link or BaseURL is then required.

type Org

type Org struct {
	ID        string       `json:"id"`
	Slug      string       `json:"slug"`
	Name      string       `json:"name"`
	Metadata  auth.AppData `json:"metadata,omitempty"`
	CreatedAt time.Time    `json:"createdAt"`
	// Fields are the extra columns WithFields declares.
	Fields auth.Fields `json:"fields,omitempty"`
}

Org is a workspace. Slug is unique across the deployment and normalized to lowercase at creation. Logo and Metadata are optional: a logo URL, and free-form JSON the app keeps on the organization (at most MaxMetadataSize bytes).

type OrgDomain

type OrgDomain struct {
	ID          string         `json:"id"`
	OrgID       string         `json:"orgId"`
	Domain      string         `json:"domain"`
	Enrollment  EnrollmentMode `json:"enrollmentMode"`
	Role        Role           `json:"role"`
	Verified    bool           `json:"verified"`
	VerifiedAt  *time.Time     `json:"verifiedAt,omitempty"`
	RecordName  string         `json:"recordName"`
	RecordValue string         `json:"recordValue"`
	CreatedAt   time.Time      `json:"createdAt"`
	// contains filtered or unexported fields
}

OrgDomain is a domain of an organization. RecordName and RecordValue are the TXT record to publish; they stay meaningful after verification, which keeps a later re-check possible.

type OrgRequest

type OrgRequest struct {
	OrgID string `json:"orgId,omitempty"`
	Page  int    `json:"page,omitempty,string"`
	Limit int    `json:"limit,omitempty,string"`
	// SortBy and SortDirection order the members list (MemberSort,
	// DefaultMemberSort ascending by default).
	SortBy        MemberSort `json:"sortBy,omitempty"`
	SortDirection Direction  `json:"sortDirection,omitempty"`
	// FilterField, FilterOperator and FilterValue narrow the members list:
	// in and not_in compare with each of the comma-separated values, and a
	// joinedAt/createdAt value is RFC 3339 (memberFilter).
	FilterField    MemberField    `json:"filterField,omitempty"`
	FilterOperator FilterOperator `json:"filterOperator,omitempty"`
	FilterValue    string         `json:"filterValue,omitempty"`
}

OrgRequest names an organization, as the input of members, role, delete and the list operations; an empty OrgID means the session's active organization. Page and Limit page the members list (strings in JSON because a Query's input arrives as URL values); Sort and Filter order and narrow it. All but OrgID are optional.

func (OrgRequest) AuditOrgID

func (r OrgRequest) AuditOrgID() string

AuditOrgID names the organization a request acts in, for plugin/audit's OrgScoped (an event's orgId), without this package importing it. Empty when the request leaves the organization to the session's active one; the operations then annotate the call with the organization they resolved.

type OrgRole

type OrgRole struct {
	ID         string             `json:"id"`
	OrgID      string             `json:"orgId"`
	Role       Role               `json:"role"`
	Permission access.Permissions `json:"permission"`
	CreatedAt  time.Time          `json:"createdAt"`
	UpdatedAt  time.Time          `json:"updatedAt"`
	// Fields are the extra columns WithFields declares
	// (organizationRole.additionalFields).
	Fields auth.Fields `json:"fields,omitempty"`
}

OrgRole is a role an organization created at run time: a name and the permissions it grants, from the access control's statements.

type OrgSummary

type OrgSummary struct {
	Org
	MemberCount int64 `json:"memberCount"`
}

OrgSummary is an organization with how many members it has.

type OrgUpdate

type OrgUpdate struct {
	Name     *string      `json:"name,omitempty"`
	Slug     *string      `json:"slug,omitempty"`
	Metadata auth.AppData `json:"metadata,omitempty"`
	// Fields change the extra columns named; the others stay.
	Fields auth.Fields `json:"fields,omitempty"`
	// contains filtered or unexported fields
}

OrgUpdate is what update changes; a nil field stays as it is. An empty Logo clears the logo; Metadata replaces the whole document.

func (*OrgUpdate) UnmarshalJSON

func (r *OrgUpdate) UnmarshalJSON(data []byte) error

UnmarshalJSON keeps top-level field keys inside update's data.

type OrgWithRole

type OrgWithRole struct {
	Org
	Role Role `json:"role"`
}

OrgWithRole is one organization and the caller's role in it.

type PermissionDoc

type PermissionDoc access.Permissions

PermissionDoc is access.Permissions as a column: JSON text on every engine.

func (*PermissionDoc) Scan

func (d *PermissionDoc) Scan(src any) error

Scan reads the JSON text the three drivers return as a string or bytes.

func (PermissionDoc) Value

func (d PermissionDoc) Value() (driver.Value, error)

Value stores the permissions as JSON text.

type PermissionRequest

type PermissionRequest struct {
	OrgID       string             `json:"orgId,omitempty"`
	Permissions access.Permissions `json:"permissions"`
	Connector   access.Connector   `json:"connector,omitempty"`
}

PermissionRequest is the input of has-permission: the permissions asked for, in an organization (the active one when OrgID is empty). Connector joins the resources, AND by default.

func (PermissionRequest) AuditOrgID

func (r PermissionRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for has-permission.

type PermissionResult

type PermissionResult struct {
	Success bool `json:"success"`
}

PermissionResult is the output of has-permission.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin adds organizations. Build it with New; the zero value has no operations.

func New

func New(opts ...Option) *Plugin

New builds the plugin with DefaultInviteTTL and the invitation page at BaseURL + DefaultInvitePage unless opts say otherwise.

func (*Plugin) APIKeyPermission

func (p *Plugin) APIKeyPermission(ctx context.Context, orgID, userID, action string) (member, allowed bool, err error)

APIKeyPermission answers plugin/apikey's question about an organization key (better-auth's checkOrgApiKeyPermission), so *Plugin satisfies apikey.Organizations without either module importing the other: member is false for someone outside orgID; a holder of the creator role may do every action (allowCreatorAllPermissions) under the default RoleAuthorizer; anyone else, and everyone under WithAuthorizer, needs the authorizer to allow action on the "apiKey" resource. Only ErrNotMember is "not a member"; any other failure is returned.

func (*Plugin) Accept

func (p *Plugin) Accept(ctx context.Context, inviteID string) (Org, error)

Accept accepts an invitation.

func (*Plugin) AcceptSuggestion

func (p *Plugin) AcceptSuggestion(ctx context.Context, domainID string) (MembershipRequest, error)

AcceptSuggestion asks to join the organization of a suggested domain; an admin approves the request.

func (*Plugin) Active

func (p *Plugin) Active(ctx context.Context) (ActiveResult, error)

Active returns the signed-in session's selection.

func (*Plugin) ActiveOrgID

func (p *Plugin) ActiveOrgID(ctx context.Context) (id string, ok bool, err error)

ActiveOrgID returns the active organization of the request's session in ctx, from Go, for an app route that scopes to it. ok is false for none, a signed-out request and an API-key request. It resolves the session first, so it works outside an operation and outside authhttp.Middleware.

func (*Plugin) AddDomain

func (p *Plugin) AddDomain(ctx context.Context, in AddDomainRequest) (OrgDomain, error)

AddDomain adds a domain to an organization, as a member with organization:update. It is unverified until VerifyDomain, unless TrustVerifiedEmail and the caller's verified email prove it at once.

func (*Plugin) AddMember

func (p *Plugin) AddMember(ctx context.Context, orgID, userID string, role Role) error

AddMember adds a user to an organization with role, from Go only, without an invitation: an import, or a product that joins users to a workspace by rule. Someone already in the organization keeps their role, or is refused with RejectExistingMembers. MaxMembers and the member hooks apply. It runs in one transaction (joining the caller's, if any), like CreateFor.

func (*Plugin) AddMemberWith

func (p *Plugin) AddMemberWith(ctx context.Context, in AddMemberRequest) error

AddMemberWith is AddMember with member fields, from Go only (better-auth's server-side addMember with additional fields); ServerOnly fields are allowed, since the caller is trusted code.

func (*Plugin) AddTeamMember

func (p *Plugin) AddTeamMember(ctx context.Context, in TeamMemberRequest) error

AddTeamMember adds a member of the organization to a team, as a member with team:update.

func (*Plugin) ApproveMembershipRequest

func (p *Plugin) ApproveMembershipRequest(ctx context.Context, requestID string) error

ApproveMembershipRequest makes the requester a member with the domain's role, as a member with member:create.

func (*Plugin) Can

func (p *Plugin) Can(ctx context.Context, orgID, userID string, req access.Request, connector access.Connector) (bool, error)

Can reports whether userID's roles in orgID pass req under connector (AND when empty), from Go: the server-side check behind an app's own routes. A non-member is ErrNotMember.

func (*Plugin) Cancel

func (p *Plugin) Cancel(ctx context.Context, inviteID string) error

Cancel withdraws a pending invitation, as a member of its organization with invitation:cancel.

func (*Plugin) CancelFor

func (p *Plugin) CancelFor(ctx context.Context, inviteID string, by auth.User) error

CancelFor withdraws a pending invitation of any organization. by is recorded as who canceled it and handed to the cancel hooks; an invitation that is unknown or no longer pending is ErrInviteInvalid.

func (*Plugin) CheckSlug

func (p *Plugin) CheckSlug(ctx context.Context, slug string) (SlugResult, error)

CheckSlug says whether a slug is free, as the signed-in user.

func (*Plugin) Create

func (p *Plugin) Create(ctx context.Context, in CreateRequest) (Org, error)

Create makes an organization with the signed-in user as its owner.

func (*Plugin) CreateFor

func (p *Plugin) CreateFor(ctx context.Context, userID string, in CreateRequest) (o Org, err error)

CreateFor makes an organization owned by userID, from Go only: a seed, an import, a sign-up flow that creates the user's workspace. The CreateGate and MaxOwned do not apply, because the caller is trusted code, not a request; the slug rules and the hooks do. No session's active organization changes. It runs in one transaction (joining the caller's, if any), so a failing hook leaves nothing behind.

func (*Plugin) CreateRole

func (p *Plugin) CreateRole(ctx context.Context, in CreateRoleRequest) (OrgRole, error)

CreateRole creates a dynamic role, as a member with ac:create who holds every permission the role grants.

func (*Plugin) CreateTeam

func (p *Plugin) CreateTeam(ctx context.Context, in CreateTeamRequest) (Team, error)

CreateTeam creates a team, as a member with team:create.

func (*Plugin) DataHooks

func (p *Plugin) DataHooks() auth.DataHooks

DataHooks enroll a user when one of their emails becomes verified: an identity created verified (a sign-up through a provider that vouches for the address) or one whose verified_at is set. Nothing else enrolls, so an unverified address never does. Off unless Domains is on.

func (*Plugin) DecodeMessageData

func (p *Plugin) DecodeMessageData(purpose auth.Purpose, raw json.RawMessage) (any, error)

DecodeMessageData restores an InvitationData the delivery outbox stored as JSON. Any other purpose is not this plugin's: (nil, nil).

func (*Plugin) Delete

func (p *Plugin) Delete(ctx context.Context, orgID string) error

Delete deletes an organization and everything in it, as a member with organization:delete.

func (*Plugin) DeleteFor

func (p *Plugin) DeleteFor(ctx context.Context, orgID string) error

DeleteFor deletes any organization and every row of it, between the delete hooks.

func (*Plugin) DeleteRole

func (p *Plugin) DeleteRole(ctx context.Context, in RoleRequest) error

DeleteRole deletes a dynamic role no member holds, as a member with ac:delete.

func (*Plugin) DomainSuggestions

func (p *Plugin) DomainSuggestions(ctx context.Context) ([]DomainSuggestion, error)

DomainSuggestions lists the organizations the signed-in user may ask to join through a verified domain in EnrollSuggestion mode.

func (*Plugin) Get

func (p *Plugin) Get(ctx context.Context, in GetRequest) (Org, error)

Get returns an organization the signed-in user belongs to.

func (*Plugin) GetFor

func (p *Plugin) GetFor(ctx context.Context, orgID string) (Org, error)

GetFor reads any organization by id; ErrNotMember when there is none, the code every other read gives for an unknown organization.

func (*Plugin) GetFull

func (p *Plugin) GetFull(ctx context.Context, in GetRequest) (FullOrg, error)

GetFull returns an organization with its members, pending invitations and teams.

func (*Plugin) GetRole

func (p *Plugin) GetRole(ctx context.Context, in RoleRequest) (OrgRole, error)

GetRole returns a dynamic role, as a member with ac:read.

func (*Plugin) HasPermission

func (p *Plugin) HasPermission(ctx context.Context, in PermissionRequest) (bool, error)

HasPermission reports whether the signed-in member holds the permissions (better-auth's hasPermission). A non-member is ErrNotMember.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core handle, builds WithHooksFrom's hooks and, when no InviteLink was given, fixes the invitation link to BaseURL + DefaultInvitePage.

func (*Plugin) Invitation

func (p *Plugin) Invitation(ctx context.Context, inviteID string) (Invite, error)

Invitation returns one of the signed-in user's pending invitations by id, for an accept page; ErrInviteInvalid for any other id.

func (*Plugin) InvitationsFor

func (p *Plugin) InvitationsFor(ctx context.Context, orgID string) ([]Invitation, error)

InvitationsFor lists every invitation of any organization, newest first, with its status, as list-invitations does for a member.

func (*Plugin) Invite

func (p *Plugin) Invite(ctx context.Context, in InviteRequest) error

Invite records an invitation for an address and mails it, as a signed-in member allowed to create invitations.

func (*Plugin) Invites

func (p *Plugin) Invites(ctx context.Context) ([]Invite, error)

Invites lists the invitations the signed-in user may accept.

func (*Plugin) List

func (p *Plugin) List(ctx context.Context) ([]OrgWithRole, error)

List returns the signed-in user's organizations.

func (*Plugin) ListAll

func (p *Plugin) ListAll(ctx context.Context, in AllOrgsRequest) (AllOrgsResult, error)

ListAll pages through every organization of the app, newest first, with member counts. Limit defaults to DefaultMembersPage and is clamped to MaxMembersPage, as the members list is.

func (*Plugin) ListDomains

func (p *Plugin) ListDomains(ctx context.Context, orgID string) ([]OrgDomain, error)

ListDomains lists an organization's domains, as a member.

func (*Plugin) ListInvitations

func (p *Plugin) ListInvitations(ctx context.Context, orgID string) ([]Invitation, error)

ListInvitations returns every invitation of an organization the signed-in user belongs to, newest first, whatever its state.

func (*Plugin) ListMembers

func (p *Plugin) ListMembers(ctx context.Context, in OrgRequest) (MembersResult, error)

ListMembers is Members with paging, sort and filter.

func (*Plugin) ListMembershipRequests

func (p *Plugin) ListMembershipRequests(ctx context.Context, orgID string) ([]MembershipRequest, error)

ListMembershipRequests lists an organization's pending requests, as a member with member:create.

func (*Plugin) ListRoles

func (p *Plugin) ListRoles(ctx context.Context, orgID string) ([]OrgRole, error)

ListRoles lists an organization's dynamic roles, as a member with ac:read.

func (*Plugin) ListTeamMembers

func (p *Plugin) ListTeamMembers(ctx context.Context, teamID string) ([]TeamMember, error)

ListTeamMembers lists a team's members, as a member of its organization.

func (*Plugin) ListTeams

func (p *Plugin) ListTeams(ctx context.Context, orgID string) ([]Team, error)

ListTeams lists an organization's teams, as a member.

func (*Plugin) ListUserTeams

func (p *Plugin) ListUserTeams(ctx context.Context) ([]Team, error)

ListUserTeams lists the teams the signed-in user is in, in every organization.

func (*Plugin) MarkDomainVerified

func (p *Plugin) MarkDomainVerified(ctx context.Context, domainID string) (OrgDomain, error)

MarkDomainVerified verifies a domain from the server, with no proof and no caller: for an operator who checked ownership another way. It is a Go API only, never a route. The domain must not be verified by another organization.

func (*Plugin) Members

func (p *Plugin) Members(ctx context.Context, orgID string) ([]MemberView, error)

Members lists an organization the signed-in user belongs to.

func (*Plugin) MembersFor

func (p *Plugin) MembersFor(ctx context.Context, in OrgRequest) (MembersResult, error)

MembersFor pages through any organization's members with in's paging, sort and filter (in.OrgID names the organization; it is required here, since there is no session to supply an active one).

func (*Plugin) Name

func (p *Plugin) Name() string

Name identifies the plugin.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists every operation; the team and role operations only when their option is on, so a route that cannot work is never mounted.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) PermissionsOf

func (p *Plugin) PermissionsOf(ctx context.Context, orgID string, role Role) (access.Permissions, error)

PermissionsOf returns the permissions role grants in orgID: the union of every part of a (possibly comma-joined) role, from the static roles (DefaultRoles merged with WithRoles) and, with DynamicRoles, orgID's dynamic roles. A part that names no known role grants nothing. Actions are deduplicated and sorted, so a token claim built from it is deterministic, and the map is a fresh copy the caller may change. It reads roles only; what a custom Authorizer would decide is not reflected here.

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds invitation mail (none without invitations, whose operation the rule would name) and verify-domain's DNS lookups.

func (*Plugin) Reject

func (p *Plugin) Reject(ctx context.Context, inviteID string) error

Reject declines one of the signed-in user's pending invitations.

func (*Plugin) RejectMembershipRequest

func (p *Plugin) RejectMembershipRequest(ctx context.Context, requestID string) error

RejectMembershipRequest declines a request, as a member with member:create; the organization is not suggested to that user again.

func (*Plugin) RemoveDomain

func (p *Plugin) RemoveDomain(ctx context.Context, domainID string) error

RemoveDomain removes a domain and its pending membership requests, as a member with organization:update. Invitations it created stay, as any invitation does, until canceled or expired.

func (*Plugin) RemoveMember

func (p *Plugin) RemoveMember(ctx context.Context, in MemberRequest) error

RemoveMember removes a member, or lets the caller leave; see doRemoveMember for who may remove whom.

func (*Plugin) RemoveMemberFor

func (p *Plugin) RemoveMemberFor(ctx context.Context, orgID, userID string) error

RemoveMemberFor removes a member from any organization, between the member hooks; the last holder of the creator role cannot be removed (ErrLastOwner), so no organization is left without one.

func (*Plugin) RemoveTeam

func (p *Plugin) RemoveTeam(ctx context.Context, teamID string) error

RemoveTeam deletes a team and its memberships, as a member with team:delete.

func (*Plugin) RemoveTeamMember

func (p *Plugin) RemoveTeamMember(ctx context.Context, in TeamMemberRequest) error

RemoveTeamMember removes someone from a team: themself, or anyone as a member with team:update.

func (*Plugin) RequirePermission

func (p *Plugin) RequirePermission(orgID func(r *http.Request) string, req access.Request) func(http.Handler) http.Handler

RequirePermission is middleware that refuses a request unless the signed-in user's roles in the organization orgID names grant req. It must be inside authhttp.Middleware. A non-member gets ErrNotMember and a member without the permission ErrForbidden, as RequireRole answers.

Example

ExamplePlugin_RequirePermission guards an app route by permission rather than by role. ds:def id=exampleplugin-requirepermission-ph8srha8 owner=@khanakia stability=stable

package main

import (
	"fmt"
	"net/http"

	"github.com/ubgo/auth/plugin/access"
	"github.com/ubgo/auth/plugin/organization"
)

func main() {
	orgs := organization.New()
	orgInPath := func(r *http.Request) string { return r.PathValue("org") }
	createProject := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusCreated) })
	mux := http.NewServeMux()
	// Inside authhttp.Middleware in a real app.
	mux.Handle("POST /orgs/{org}/projects", orgs.RequirePermission(orgInPath, access.Request{"project": access.All("create")})(createProject))
	fmt.Println("mounted")
}
Output:
mounted

func (*Plugin) RequireRole

func (p *Plugin) RequireRole(orgID func(r *http.Request) string, role Role) func(http.Handler) http.Handler

RequireRole is middleware that refuses a request unless the signed-in user holds at least role in the organization orgID names — read from the path, a header or a subdomain, as the app routes. It must be inside authhttp.Middleware. A non-member gets ErrNotMember, a lower role ErrForbidden, so a stranger learns nothing about the organization.

func (*Plugin) Requires

func (p *Plugin) Requires() []string

Requires names the email plugin when invitations are addressed by email, which they are by default; InviteBy without email, or WithoutInvitations, requires nothing.

func (*Plugin) Role

func (p *Plugin) Role(ctx context.Context, orgID string) (Role, error)

Role returns the signed-in user's role in an organization, or ErrNotMember.

func (*Plugin) RoleAuthorizer

func (p *Plugin) RoleAuthorizer() Authorizer

RoleAuthorizer is the default Authorizer: allowed when one of the request's roles, static or dynamic in the request's organization, grants the whole request. A role that no longer exists grants nothing.

func (*Plugin) RoleOf

func (p *Plugin) RoleOf(ctx context.Context, orgID, userID string) (Role, error)

RoleOf returns userID's role in orgID, or ErrNotMember.

func (*Plugin) SessionEnded

func (p *Plugin) SessionEnded(ctx context.Context, userID, id string) error

SessionEnded drops the ended session's selection.

func (*Plugin) SessionReplaced

func (p *Plugin) SessionReplaced(ctx context.Context, old, new auth.Session) error

SessionReplaced moves a pending second-factor session's selection to the full session, so an organization chosen before the second factor is still active after it.

func (*Plugin) SetActive

func (p *Plugin) SetActive(ctx context.Context, in SetActiveRequest) (ActiveResult, error)

SetActive makes an organization the signed-in session's active one, or clears the selection.

func (*Plugin) SetActiveTeam

func (p *Plugin) SetActiveTeam(ctx context.Context, teamID string) (ActiveResult, error)

SetActiveTeam makes a team the session's active one (and its organization the active organization), or clears it with an empty id.

func (*Plugin) SetRole

func (p *Plugin) SetRole(ctx context.Context, in SetRoleRequest) error

SetRole changes a member's role, as a signed-in member allowed to update members.

func (*Plugin) SetRoleFor

func (p *Plugin) SetRoleFor(ctx context.Context, orgID, userID string, role Role) error

SetRoleFor gives a member of any organization role. The caller acts with the creator role, so it may grant or take it, but the last holder still cannot be demoted (ErrLastOwner) and the role hooks run.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)

Sweep deletes invitations that expired unaccepted before cutoff, and the active selections of sessions whose hard stop is before cutoff. Accepted invitations stay: they record who invited whom.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares every table this plugin can bring into a statement. The team and role tables are declared only when their option is on, so an app without teams has no auth_org_team to rename or migrate.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates the tables for engine, or fails for an engine the core does not support. 0001 is the shipped schema; 0002 adds the organization's logo and metadata and the invitation's status and team; 0003 the active selection; 0004 and 0005 the team and role tables when their option is on; 0006 the invitation's identifier kind and value (InviteBy); 0007 the domain tables when Domains is on.

func (*Plugin) Update

func (p *Plugin) Update(ctx context.Context, in UpdateRequest) (Org, error)

Update changes an organization, as a member with organization:update.

func (*Plugin) UpdateDomain

func (p *Plugin) UpdateDomain(ctx context.Context, in UpdateDomainRequest) (OrgDomain, error)

UpdateDomain changes a domain's enrollment mode or role, as a member with organization:update.

func (*Plugin) UpdateFor

func (p *Plugin) UpdateFor(ctx context.Context, orgID string, data OrgUpdate) (o Org, err error)

UpdateFor changes any organization. ServerOnly fields are allowed, since the caller is trusted code.

func (*Plugin) UpdateRole

func (p *Plugin) UpdateRole(ctx context.Context, in UpdateRoleRequest) (OrgRole, error)

UpdateRole renames a dynamic role or replaces its permissions, as a member with ac:update.

func (*Plugin) UpdateTeam

func (p *Plugin) UpdateTeam(ctx context.Context, in UpdateTeamRequest) (Team, error)

UpdateTeam renames a team, as a member with team:update.

func (*Plugin) UserSessionsEnded

func (p *Plugin) UserSessionsEnded(ctx context.Context, userID, exceptID string) error

UserSessionsEnded drops the selections of every session of userID but exceptID's.

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables declares the rows that belong to the user in user_id, so DeleteUser removes them (no foreign key does, ADR-0002): memberships, team memberships and active selections. Invitations keep their invited_by: they belong to the organization, not to the inviter.

func (*Plugin) Validate

func (p *Plugin) Validate(c *auth.Config) error

Validate asserts the plugin can function. Invitations to a kind no plugin registers would match nobody, and discovering that at start-up beats discovering it when the first invite goes nowhere. Requires only checks plugin names, so every kind is checked here too: a plugin that is merely named "email" does not make invitations deliverable.

func (*Plugin) VerifyDomain

func (p *Plugin) VerifyDomain(ctx context.Context, domainID string) (OrgDomain, error)

VerifyDomain checks a domain's proof, as a member with organization:update.

type RequestIDRequest

type RequestIDRequest struct {
	RequestID string `json:"requestId"`
}

RequestIDRequest names a membership request, as the input of approve-membership-request and reject-membership-request.

type RequestStatus

type RequestStatus string

RequestStatus is the state of a membership request made from a suggestion.

const (
	RequestPending  RequestStatus = "pending"
	RequestApproved RequestStatus = "approved"
	RequestRejected RequestStatus = "rejected"
)

Membership request states.

type Role

type Role string

Role is a membership role. A named type rather than a bare string so a role cannot be passed where an org id is expected — both are strings and they appear adjacent in almost every membership signature.

A member may hold several roles; they are stored comma-joined in one column ("admin,billing"), as better-auth stores them, and Parts splits them. JoinRoles builds one.

const (
	//ds:def id=roleowner-szv8n5ye owner=@khanakia stability=stable
	RoleOwner Role = "owner"
	//ds:def id=roleadmin-cghbz3fj owner=@khanakia stability=stable
	RoleAdmin Role = "admin"
	//ds:def id=rolemember-k755wabe owner=@khanakia stability=stable
	RoleMember Role = "member"
)

Built-in membership roles, granted DefaultRoles' permissions. An app adds its own with WithRoles (fixed at start-up) or DynamicRoles (created per organization at run time); every role check goes through the permission statements (HasPermission), never through the role's name.

func JoinRoles

func JoinRoles(roles ...Role) Role

JoinRoles is the stored form of several roles, duplicates dropped, in the order given.

func (Role) AtLeast

func (r Role) AtLeast(need Role) bool

AtLeast reports whether r is at least as privileged as need.

func (Role) Has

func (r Role) Has(role Role) bool

Has reports whether r holds role among its parts.

func (Role) Parts

func (r Role) Parts() []Role

Parts splits a stored role into the roles it holds, trimmed, empty ones dropped.

func (Role) Rank

func (r Role) Rank() int

Rank returns the privilege level of the built-in roles, higher being more privileged; for several roles, the highest. Custom roles rank 0: their power is their permissions, which HasPermission checks.

Derived from position in RoleValues rather than stored as a separate number, so adding a role in the right place is the only edit required and the two cannot disagree.

func (*Role) UnmarshalJSON

func (r *Role) UnmarshalJSON(b []byte) error

UnmarshalJSON accepts a role as a string ("admin" or "admin,billing") or, as better-auth sends several, an array of strings.

type RoleRequest

type RoleRequest struct {
	OrgID    string `json:"orgId,omitempty"`
	RoleID   string `json:"roleId,omitempty"`
	RoleName Role   `json:"roleName,omitempty"`
}

RoleRequest names a dynamic role by id, or by name when RoleID is empty, as the input of delete-role and get-role.

func (RoleRequest) AuditOrgID

func (r RoleRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for delete-role and get-role.

type RoleResult

type RoleResult struct {
	Role Role `json:"role"`
}

RoleResult is the output of role: the caller's role in the organization.

type RoleUpdate

type RoleUpdate struct {
	RoleName   *Role              `json:"roleName,omitempty"`
	Permission access.Permissions `json:"permission,omitempty"`
	Fields     auth.Fields        `json:"fields,omitempty"`
	// contains filtered or unexported fields
}

RoleUpdate is what update-role changes; nil stays.

func (*RoleUpdate) UnmarshalJSON

func (r *RoleUpdate) UnmarshalJSON(data []byte) error

UnmarshalJSON keeps top-level field keys inside update-role's data.

type RolesResult

type RolesResult struct {
	Roles []OrgRole `json:"roles"`
}

RolesResult is the output of list-roles.

type SetActiveRequest

type SetActiveRequest struct {
	OrgID   string `json:"orgId,omitempty"`
	OrgSlug string `json:"orgSlug,omitempty"`
}

SetActiveRequest is the input of set-active: the organization to make active, by id or by slug. Both empty clears the selection, as better-auth's organizationId: null does.

func (SetActiveRequest) AuditOrgID

func (r SetActiveRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for set-active.

type SetRoleRequest

type SetRoleRequest struct {
	OrgID  string `json:"orgId,omitempty"`
	UserID string `json:"userId"`
	Role   Role   `json:"role"`
}

SetRoleRequest is the input of set-role. Role may hold several roles.

func (SetRoleRequest) AuditOrgID

func (r SetRoleRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for set-role.

func (SetRoleRequest) TargetUserID

func (r SetRoleRequest) TargetUserID() string

TargetUserID is MemberRequest's, for set-role.

type SlugRequest

type SlugRequest struct {
	Slug string `json:"slug"`
}

SlugRequest is the input of check-slug.

type SlugResult

type SlugResult struct {
	Slug      string `json:"slug"`
	Available bool   `json:"available"`
}

SlugResult says whether a slug may be used, and the normalized form that create would store. A slug that fails SlugPattern is not available.

type SuggestionsResult

type SuggestionsResult struct {
	Suggestions []DomainSuggestion `json:"suggestions"`
}

SuggestionsResult is the output of domain-suggestions.

type TXTResolver

type TXTResolver interface {
	LookupTXT(ctx context.Context, name string) ([]string, error)
}

TXTResolver looks up TXT records; *net.Resolver is one.

type Team

type Team struct {
	ID          string    `json:"id"`
	OrgID       string    `json:"orgId"`
	Name        string    `json:"name"`
	CreatedAt   time.Time `json:"createdAt"`
	UpdatedAt   time.Time `json:"updatedAt"`
	MemberCount int64     `json:"memberCount"`
	// Fields are the extra columns WithFields declares.
	Fields auth.Fields `json:"fields,omitempty"`
}

Team is a sub-group of an organization. MemberCount is counted when the team is read, not stored, so it cannot drift from the rows.

type TeamMember

type TeamMember struct {
	TeamID    string        `json:"teamId"`
	User      auth.UserView `json:"user"`
	CreatedAt time.Time     `json:"createdAt"`
}

TeamMember is one member of a team, with the user fields safe to show.

type TeamMemberChange

type TeamMemberChange struct {
	OrgID  string
	TeamID string
	UserID string
}

TeamMemberChange is a team membership a team member hook sees.

type TeamMemberRequest

type TeamMemberRequest struct {
	TeamID string `json:"teamId"`
	UserID string `json:"userId"`
}

TeamMemberRequest names a member of a team, as the input of add-team-member and remove-team-member.

func (TeamMemberRequest) TargetUserID

func (r TeamMemberRequest) TargetUserID() string

TargetUserID is MemberRequest's, for add-team-member and remove-team-member.

type TeamMembersResult

type TeamMembersResult struct {
	Members []TeamMember `json:"members"`
}

TeamMembersResult is the output of list-team-members.

type TeamRequest

type TeamRequest struct {
	TeamID string `json:"teamId"`
}

TeamRequest names a team, as the input of remove-team, set-active-team and list-team-members. An empty TeamID clears the active team in set-active-team, and means the active team in list-team-members.

type TeamUpdate

type TeamUpdate struct {
	Name   *string     `json:"name,omitempty"`
	Fields auth.Fields `json:"fields,omitempty"`
	// contains filtered or unexported fields
}

TeamUpdate is what update-team changes; nil stays.

func (*TeamUpdate) UnmarshalJSON

func (r *TeamUpdate) UnmarshalJSON(data []byte) error

UnmarshalJSON keeps top-level field keys inside update-team's data.

type TeamsConfig

type TeamsConfig struct {
	// MaxTeams caps the teams of one organization; zero is no cap.
	MaxTeams int
	// MaxMembersPerTeam caps the members of one team; zero is no cap.
	MaxMembersPerTeam int
	// AllowRemovingAllTeams lets an organization's last team be removed;
	// by default it cannot be, so a team-scoped app always has one.
	AllowRemovingAllTeams bool
	// NoDefaultTeam stops create from making a team, with the creator in
	// it, named after the organization (better-auth's defaultTeam.enabled
	// false).
	NoDefaultTeam bool
	// DefaultTeamName names the default team (customCreateDefaultTeam);
	// the organization's name when nil.
	DefaultTeamName func(ctx context.Context, org Org, creator auth.User) (string, error)
}

TeamsConfig configures teams (Teams), better-auth's teams option.

type TeamsResult

type TeamsResult struct {
	Teams []Team `json:"teams"`
}

TeamsResult is the output of list-teams and list-user-teams.

type UpdateDomainRequest

type UpdateDomainRequest struct {
	DomainID   string          `json:"domainId"`
	Enrollment *EnrollmentMode `json:"enrollmentMode,omitempty"`
	Role       *Role           `json:"role,omitempty"`
}

UpdateDomainRequest is the input of update-domain; nil fields stay.

type UpdateRequest

type UpdateRequest struct {
	OrgID string    `json:"orgId,omitempty"`
	Data  OrgUpdate `json:"data"`
}

UpdateRequest is the input of update: the organization (the active one when OrgID is empty) and the changes.

func (UpdateRequest) AuditOrgID

func (r UpdateRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for update.

type UpdateRoleRequest

type UpdateRoleRequest struct {
	OrgID    string     `json:"orgId,omitempty"`
	RoleID   string     `json:"roleId,omitempty"`
	RoleName Role       `json:"roleName,omitempty"`
	Data     RoleUpdate `json:"data"`
}

UpdateRoleRequest is the input of update-role.

func (UpdateRoleRequest) AuditOrgID

func (r UpdateRoleRequest) AuditOrgID() string

AuditOrgID is OrgRequest's, for update-role.

type UpdateTeamRequest

type UpdateTeamRequest struct {
	TeamID string     `json:"teamId"`
	Data   TeamUpdate `json:"data"`
}

UpdateTeamRequest is the input of update-team.

type UserMembership

type UserMembership struct {
	// Role is the user's role in the organization the join was parameterised
	// with. Empty when they are not a member, because the join is LEFT and the
	// binding coalesces the null away.
	Role Role

	// JoinedAt is nil when they are not a member.
	//
	// A pointer rather than a bare time.Time because this column arrives via a
	// LEFT join and database/sql cannot scan NULL into a time.Time. Coalescing
	// it would mean inventing a fallback timestamp, which is worse than nil: a
	// zero time reads as a real value from 1 January year 1 and will eventually
	// be formatted into someone's interface.
	JoinedAt *time.Time
}

UserMembership is this plugin's contribution to a composite user.

It demonstrates the other half of struct extension: unlike phone and username, which borrow the core's shared identity table under a private alias, this plugin contributes columns from a table it owns outright. Nothing in the Composite mechanism cares which of the two it is — a plugin supplies a join and bindings, and where the columns physically live is its own business.

Nested rather than flattened into the user struct, following the same rule as every other plugin here: the columns live in auth_org_member, a table of this plugin's own, so they read as u.Membership.Role rather than u.OrgRole.

func (*UserMembership) IsMember

func (m *UserMembership) IsMember() bool

IsMember reports whether the user belongs to the organization the composite query was built for. Prefer it over comparing Role to "" at the call site, so the meaning of the empty role lives in one place.

Directories

Path Synopsis
Package orgjwt puts the session's active organization into the tokens plugin/jwt mints: its id, slug, the caller's role in it and the permissions that role grants, Clerk's "o" organization claim.
Package orgjwt puts the session's active organization into the tokens plugin/jwt mints: its id, slug, the caller's role in it and the permissions that role grants, Clerk's "o" organization claim.
Package orgtest is the organization counterpart of authtest's user factory (better-auth's ctx.test createOrganization / saveOrganization / addMember): build an organization in memory, then save it through the plugin's real Go API so every rule and hook runs.
Package orgtest is the organization counterpart of authtest's user factory (better-auth's ctx.test createOrganization / saveOrganization / addMember): build an organization in memory, then save it through the plugin's real Go API so every rule and hook runs.

Jump to

Keyboard shortcuts

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