team

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package team manages the people in an organization, the workspaces they act in, and the creation of organizations themselves.

It is the other half of internal/invite. An invitation decides who may join; everything here decides what happens after they have — which role they hold, which workspaces that role reaches, and how somebody leaves. Until this package existed the only correction available was SQL against the database.

Three decisions shape it.

**A member manages only ranks strictly below their own, and owners are the exception** (D30). An admin re-roles and removes editors and viewers, never another admin and never themselves; only an owner manages admins, and an owner manages other owners because an owner already holds everything, so there is no escalation left for the rule to prevent. The last owner of an organization cannot be removed or demoted by anybody, which is what stops one being orphaned. The table this implements is written out in docs/build-notes/phase-details/m28.md, before this code existed.

**A workspace-scoped membership only ever adds** (D31). Permissions are the union of every membership matching the workspace and the effective role is the lowest rank among them, which is what GetUserPermissions and GetUserRoleInWorkspace already compute. So granting somebody a role in one workspace widens what they can do there and narrows nothing anywhere — the evaluator is not touched by this milestone, and the control that issues one says so.

**A membership's authority is bounded by the membership that carried it** (D44). D31's union answers what somebody may do in the workspace they are acting in; it does not say whose membership the authority came from, and every member write here scoped by organization alone. So an organization-wide viewer who was granted admin in one workspace resolved, in that workspace, as an admin — and re-roled their own organization-wide membership with it (F27). Each write below therefore asks auth.MembershipAuthority for the authority that reaches the *target's* scope, where an organization-wide object is reached only by an organization-wide membership, and both bounds — who may be acted on, and what may be handed out — are evaluated against that rank rather than against the identity's. The evaluator is untouched; what changed is which membership a write is authorized by.

**A workspace holding any link refuses to be deleted** (D32). links, tags and folders all cascade from workspaces, Phase 1 has no trash to restore from, and archiving is deliberately not an escape hatch: an archived link keeps its alias and its click history. The guard goes in front of the cascade, and the links have to be deleted first.

M28.5 adds the exit, and two more decisions with it.

**An organization holding any link refuses to be deleted** (D37), which is D32 applied one level up for the reason that an org-level cascade through the same links would make D32 bypassable by deleting above it.

**Belonging to nothing is a real state** (D36). Deleting an organization proceeds even when it leaves somebody with no membership anywhere; their account survives, holding no role and therefore no permission, and the product offers them an organization of their own instead of erroring at them. That puts one seam in this package worth naming: an account in that state cannot hold orgs.create, so CreateOrganization has a second door for it — see there.

Index

Constants

View Source
const (
	PermMembersRead    = "members.read"
	PermMembersWrite   = "members.write"
	PermWorkspaceRead  = "workspace.read"
	PermWorkspaceWrite = "workspace.write"
	PermOrgsCreate     = "orgs.create"
	PermOrgDelete      = "org.delete"
)

The permissions this package enforces.

members.write is the same slug internal/invite guards issuing an invitation with, named again here rather than imported: the two are separate call sites for one permission, and a package that enforces a permission says which one in its own vocabulary. members.read is enforced for the first time anywhere by this package — it has been seeded since Phase 1 with nothing reading it.

workspace.write already guards changing a workspace's settings; creating, renaming and deleting one are the same authority over the same object, so no new permission is introduced for them. orgs.create is new (D16, 01300).

org.delete is the opposite case: seeded in Phase 1's 00700_seed.sql, granted to owner alone, and until M28.5 it gated nothing at all. No migration adds it here because there is nothing to add — what was missing was the operation, not the permission.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Audit records every change made here. Nil records nothing.
	Audit audit.Recorder
	Log   *slog.Logger
}

Config is what a Service needs. Its own struct rather than config.Config, matching every other service in this tree.

type GrantInput

type GrantInput struct {
	UserID      uuid.UUID
	WorkspaceID uuid.UUID
	Role        string
}

GrantInput describes workspace-scoped access being given to somebody who is already in the organization.

type Member

type Member struct {
	// ID is the membership's id, not the user's. Every operation here acts on a
	// membership: removing somebody from one workspace and removing them from
	// the organization are the same verb applied to different rows.
	ID     uuid.UUID `json:"id"`
	UserID uuid.UUID `json:"user_id"`
	Email  string    `json:"email"`
	Name   string    `json:"name"`
	Role   string    `json:"role"`
	// RoleRank is carried so a control can order and compare without a second
	// lookup, and so the rank rules are visible to whoever reads the response.
	RoleRank int32 `json:"role_rank"`
	// WorkspaceID is nil for a membership covering every workspace in the
	// organization, which is what registration and invitation redemption both
	// create. A set one covers exactly that workspace and adds to whatever else
	// the person holds.
	WorkspaceID   *uuid.UUID `json:"workspace_id,omitempty"`
	WorkspaceName string     `json:"workspace_name,omitempty"`
	// Manageable is whether the actor who asked may re-role or remove this row.
	// Computed against the asker rather than stored, because it is a property of
	// the request: it is what lets a page draw the controls that will work and
	// omit the ones that would answer 403.
	Manageable bool `json:"manageable"`
	// IsSelf marks the asker's own membership. An admin's own row is not
	// manageable — self is not strictly below self — and saying which row is
	// theirs is what makes that read as a rule rather than as a bug.
	IsSelf    bool      `json:"is_self"`
	CreatedAt time.Time `json:"created_at"`
}

Member is one membership as an administrator sees it.

One row per membership rather than per person, because a user may hold an organization-wide membership and a workspace-scoped one at the same time and under D31 the two add. Collapsing them would hide the second grant behind the first, which is the grant somebody would go looking for.

type Organization

type Organization struct {
	ID   uuid.UUID `json:"id"`
	Name string    `json:"name"`
	Slug string    `json:"slug"`
	// IsPersonal is false for everything created here. The flag marks the
	// organization registration provisions alongside an account — "your own
	// space" — and an organization somebody deliberately created to share is not
	// that, whatever they end up using it for.
	IsPersonal    bool      `json:"is_personal"`
	WorkspaceID   uuid.UUID `json:"workspace_id"`
	WorkspaceName string    `json:"workspace_name"`
	CreatedAt     time.Time `json:"created_at"`
}

Organization is a newly created organization and the workspace it was provisioned with.

The workspace is part of the answer rather than a detail: an organization with nothing to work in is not usable, so the call that makes one makes both, and the response says where to go.

type Role

type Role struct {
	Slug        string `json:"slug"`
	Name        string `json:"name"`
	Description string `json:"description"`
	Rank        int32  `json:"rank"`
}

Role is one choice in a role control.

type Service

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

Service manages members, workspaces and organization creation.

func NewService

func NewService(pool *pgxpool.Pool, cfg Config) *Service

func (*Service) ChangeRole

func (s *Service) ChangeRole(
	ctx context.Context, actor *auth.Identity, membershipID uuid.UUID, roleSlug string,
) error

ChangeRole re-roles one membership.

Two bounds, and they are different questions. The membership must be one this actor may manage at all — strictly below their own rank, owners excepted (D30) — and the role being granted must be at or below the actor's own, which is m28.md's "nobody grants a role binding tighter than their own" and the same ceiling an invitation carries (D28).

So an admin may promote an editor to admin and then find they can no longer manage them. That is not an oversight: the ceiling is about what authority may be handed out, and strictly-below is about who may be acted on, and an admin who mints a peer has done something an invitation already let them do.

Both bounds are read from the authority that reaches the *membership being changed* (D44), which is why the membership is loaded before the role is resolved: until the target's scope is known, there is no way to say which of the actor's memberships is doing the granting.

func (*Service) CreateOrganization

func (s *Service) CreateOrganization(
	ctx context.Context, actor *auth.Identity, name string,
) (*Organization, error)

CreateOrganization provisions an organization, its first workspace and an owner membership for the caller, in one transaction.

The provisioning is auth.ProvisionOrganization — literally the function registration calls — rather than a second implementation of the same four writes. That is deliberate: the tenancy invariants (an organization always has a workspace, and always has an owner, both written in the transaction that created it) are the kind that hold until somebody writes them out a second time slightly differently.

Gated on orgs.create (D16), which on a default instance is held by the account from the setup form and nobody else — see 01300_orgs_create.sql for why that is a role grant rather than a check against how the account was made. The permission is also the call site a future entitlement check would hang on — unscheduled, and Phase 3 left commercial work a candidate rather than taking it (D108); nothing here is billing-shaped, and the point of naming it now is that the check has somewhere to go without a schema change.

The caller becomes the owner. Not a parameter, and not settable: an organization created on somebody else's behalf would be an organization nobody asked for, and the account that holds orgs.create is the one that wanted it.

The first-organization seam (D36, recorded against D16)

An account that belongs to no organization holds no role, therefore holds no permissions, therefore does not hold orgs.create. Since M28.5 that state is reachable — deleting an organization leaves its members with one fewer, and possibly none — and the product's answer to it is to offer that account an organization of its own. Without a second door, the offer would lead straight to a 403.

**The mechanism is a membership count, read inside this transaction, at this one call site.** It is written here rather than folded into the permission evaluator on purpose. An identity that synthesised orgs.create for itself would carry that grant into every Can() call and every affordance the templates draw from one, and the blast radius of getting it wrong would be the whole authorization surface; a count read where it is used authorizes exactly this operation and is findable by grepping for the permission.

**Why this is not a second authorization axis.** D16 made orgs.create a grant rather than a check on how an account was made, because a provenance test — "did this account self-register?" — is a parallel authorization system that RBAC cannot see, cannot audit and cannot revoke. A membership count is not that. It is a check on *present state*, it is monotone in the direction that closes rather than opens (the moment the account has any membership at all, only the permission answers), and it cannot escalate: an account with no memberships can reach exactly one operation, whose entire effect is to give that account an owner membership — which is where the permission takes over. The zero-membership account is therefore not a role beside RBAC; it is the empty case RBAC has no row for.

Read inside the transaction because everything this call does is, and not because that serializes it. A count cannot be locked — the check-then-act organizations.sql warns about in its own preamble, and which LockOrganizations avoids by selecting the rows a decision is made on FOR UPDATE. At read committed each statement takes its own snapshot, so a redemption committing after this read is invisible to it and two calls racing can both see zero. The race is left open rather than closed, on the paragraph above: the one operation a zero-membership account can reach gives that account an owner membership, so losing it costs one more organization the account legitimately owns and nothing else.

**No credential-type check, and that is deliberate rather than an omission.** An API key used to be able to walk through this door: its owner could be removed from the organization, the key would still authenticate, and the count it then read was zero — so a key scoped to links.read alone could create an organization and own it, which is a bypass of orgs.create rather than ordinary use of it. The answer is not a requireSessionActor here. Branching on credential type outside NonDelegableScopes and D43 is itself a defect, and one more branch would leave the *state* the door opens on intact. The state is what was wrong: an authenticated key now always has a live membership in the organization it was issued into, because Authenticate refuses one whose owner does not, so the count a key reads here is never zero. A key holding orgs.create still creates organizations, which is what D36 and its test say it may do.

func (*Service) CreateWorkspace

func (s *Service) CreateWorkspace(
	ctx context.Context, actor *auth.Identity, name string,
) (*Workspace, error)

CreateWorkspace adds a workspace to the caller's organization.

Organization-wide workspace.write, not the identity's union. The two differ for exactly one actor and the difference is the whole point: `Can` answers from every membership matching the workspace being acted in (D31), so a workspace-scoped admin holding workspace.write in their own corner passed it and added workspaces to the entire organization. There is no target workspace to resolve the permission against — that is what made the old gate look right — but the object being created belongs to the organization, so the organization is the scope the authority has to cover.

This is D44's shape, and the same one `invite.orgWideAuthority` uses for the same reason: `Can` first, so somebody holding the permission nowhere still gets the plain refusal, then `In(nil)`, which only an organization-wide membership satisfies. It was the last member-adjacent write in this package still answering the old question (F63).

func (*Service) DeleteOrganization

func (s *Service) DeleteOrganization(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

DeleteOrganization removes an organization and everything the schema hangs off it, and it is the first operation `org.delete` has ever gated.

The permission has been seeded and held by owners since Phase 1 with nothing behind it. M28's rank rules already forbid an admin acquiring it: granting the owner role requires being an owner (resolveRole's ceiling), so the set of accounts that can reach this is exactly the set an owner chose.

Which organization

The one the caller is acting in, named by id. An id that is not the caller's current organization is not-found — the same answer one that never existed gets, so ids cannot be probed, and consistent with every other read in this package. The path parameter is therefore a confirmation rather than a selector, which is the right shape for an irreversible operation: pasting the wrong id deletes nothing.

What it refuses, and why each refusal is a rule rather than a check

**The instance's last organization.** An instance with no organization has no path back that does not involve SQL — the same argument that refuses the last owner and the last workspace, one level up.

**An organization still holding any link** (D37), archived ones included. D32 refuses this for a workspace; an organization-level cascade through the same links would make that rule bypassable by deleting one level up. The cost is stated rather than hidden: with no bulk delete until Phase 2+, emptying a large organization is a link at a time.

Both guards lock the rows they count before counting them, so two administrators acting at once cannot each pass a check the other invalidates. The lock on the organization rows also blocks a workspace being created in one while this decides, and the lock on the workspaces blocks a link being created in one — see the notes in organizations.sql.

What it does not refuse on

**Members left with no organization at all** (D36). Deletion proceeds; the accounts survive with no membership, and the session path treats that as an empty state rather than a broken instance. That is the expensive answer and it is most of this milestone; auth.ErrNoWorkspace is where it lands.

What survives

Two things, enumerated rather than counted. This paragraph opened with *the audit trail, and nothing else* and then described a second survivor two sentences later, which is how it also failed to notice a third (F106).

The audit trail. `audit_logs.organization_id` carries no foreign key, so every record this organization wrote outlives it, including the `organization.deleted` record emitted below — whose metadata carries the name and slug precisely because the row that held them is gone.

The aliases of trashed links that received traffic, in `reserved_aliases`. The link guard does not decide this, which is what an earlier version of this comment got wrong (F28): it counts live links, and excludes soft-deleted ones on purpose, so an organization can reach this line still holding trashed links for the rest of their trash window. The cascade hard-deletes them, and the purge job — the only other writer of `reserved_aliases` — never sees them. So the reservation is made here, in this transaction, at PurgeExpiredLinks' threshold; an alias that never received a click is released, because nothing in the wild points at it.

**The analytics rollups no longer survive, and used to.** `link_click_daily`, `link_dimension_daily` and `workspace_click_daily` carry `workspace_id` with no foreign key, so nothing cascaded them and they outlived the tenancy they described. Not a disclosure — every reader scopes to a live workspace, so the rows were unreachable rather than exposed — but stale aggregate data with no owner, and a sentence above that was not true. `DeleteOrganizationRollups` takes them in this transaction, before the cascade removes the workspaces that are the only way to name them.

**It preserves the aliases on the shared default domain, and only those** (F118). `reserved_aliases` is keyed to `domain_id` with `ON DELETE CASCADE`, and a workspace's own registered hostname cascades from the workspace, which cascades from here — so for a link on a custom hostname the reservation inserted a line above is removed by the cascade of this same statement. The inserts are wasted rather than wrong.

**Nothing is done about that, and the reasons are worth having in one place.** The exposure F28 is about is the shared default domain, whose `organization_id` is NULL and which this teardown does not touch, so the path that matters is intact. For a custom hostname the domain row is destroyed too, and re-serving one of its aliases would require re-registering the hostname *and* passing the TXT check — at which point whoever did that controls the name anyway and the reservation was never what protected anybody. And every available repair is worse: `RESTRICT` makes organization deletion fail outright, `SET NULL` is impossible because `domain_id` is half the primary key, and re-keying reservations by hostname would reserve aliases on a name whose next owner proved control of it.

Nothing else is preserved. Holding anything else back would be keeping rows nobody can reach on behalf of an organization nobody can enter.

func (*Service) DeleteWorkspace

func (s *Service) DeleteWorkspace(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

DeleteWorkspace removes a workspace, and refuses while anything depends on it.

Two refusals, and they are different kinds of protection.

**Any link at all** (D32). links, tags and folders cascade from workspaces, so this delete is a redirect outage for every alias in it, and Phase 1 has no trash to restore one from. Archived links count: an archived link keeps its alias and its click history, so cascading it away would be silent data loss dressed as tidying up. The cost the owner accepted knowingly is that emptying a workspace is a link-at-a-time job, because Phase 2 has neither bulk delete nor a cross-workspace move.

**The organization's last workspace.** Every member of an organization resolves into one of its workspaces to act at all, and ResolveWorkspaceForUser reports finding none as a broken instance rather than as an empty state — so deleting the last one would leave every member of the organization unable to authenticate. Guarded for the same reason the last owner is: the state is unreachable by any other route and unrecoverable without SQL.

**What survives it.** The aliases of trashed links that received traffic. The link refusal is about live links and says nothing about trashed ones, so this delete is a third path by which an alias leaves its row — beside the purge job and the rename — and it is the one that used to release the alias for free (F28). All three now reserve at the same threshold.

func (*Service) Grant

func (s *Service) Grant(ctx context.Context, actor *auth.Identity, in GrantInput) (*Member, error)

Grant issues a workspace-scoped membership.

This is the writer the COALESCE uniqueness index in 00200 has been waiting for since Phase 1: `(user_id, organization_id, coalesce(workspace_id, …))` permits exactly one organization-wide membership and one per workspace, and until now nothing created the second kind.

**It adds and never narrows** (D31). Permissions resolve as the union of every matching membership and the effective role is the lowest rank among them, so an org-wide editor granted admin in one workspace is an admin there and an editor everywhere else. The reverse — org admin, viewer in one workspace — is not expressible, and every control that offers this says so.

The person must already be a member of the organization — by any membership, organization-wide or scoped to one workspace. Somebody with none is invited rather than granted, because a grant is not a way into an organization and making it one would be a second admission path beside the one D27 bound to an address.

**The workspace being granted into is the scope this is authorized in** (D44). Holding members.write somewhere in the organization is not holding it everywhere, and F27's first move was a workspace-scoped admin granting themselves a role in a workspace they had no membership in at all — after which writableWorkspace let them rename it, having correctly refused three calls earlier. This is the same question canInWorkspace asks for workspace.write, asked at last by a member operation.

func (*Service) Members

func (s *Service) Members(ctx context.Context, actor *auth.Identity) ([]Member, error)

Members lists the organization's memberships, most powerful first.

members.read, enforced here for the first time anywhere: the permission has been seeded since Phase 1 with nothing consulting it. Editors and viewers hold it, so everybody in an organization can see who else is in it — which is the point of belonging to one — while changing anything needs members.write.

func (*Service) Remove

func (s *Service) Remove(ctx context.Context, actor *auth.Identity, membershipID uuid.UUID) error

Remove ends one membership.

The membership, not the account. Somebody removed from an organization keeps their user row, their password and every other membership they hold — which is what makes removal reversible by re-inviting rather than by restoring a backup, and what D6's membership-only redemption was shaped for.

Removing a workspace-scoped membership is the same call: it withdraws the access that row added and leaves the organization-wide one alone.

func (*Service) RenameWorkspace

func (s *Service) RenameWorkspace(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, name string,
) (*Workspace, error)

RenameWorkspace changes a workspace's name, and its slug with it.

The permission is read against the *target* workspace rather than taken from the identity. An identity carries the permissions of the workspace its request is acting in, and under D31 somebody can hold workspace.write in one workspace and nothing at all in the next — so trusting the identity here would let a workspace-scoped admin rename a workspace they cannot even see.

func (*Service) Roles

func (s *Service) Roles(ctx context.Context, actor *auth.Identity) ([]Role, error)

Roles lists the roles this actor may assign somewhere: their union rank and below, most powerful first, with D43's cap applied for a key.

Read from the seeded rows rather than listed in Go, so the four built-in roles have one definition.

**It is not a promise that every entry will be accepted, and it used to say it was** (F120). `resolveRole` compares against `here.Rank` — the authority of the membership covering the *target* — and this list is rendered once for a page whose rows target different workspaces and whose grant form chooses its target on submit. An organization-wide viewer who is admin in one workspace has a union rank of admin, so admin appears here and is refused for every target their organization-wide membership covers.

Narrowing to the organization-wide rank, which is what internal/invite's Roles does, is **wrong here** and right there: an invitation admits somebody to the whole organization, so one ceiling governs it. A role assignment is per membership, and filtering on the weakest authority would hide admin from the workspace-scoped admin who may genuinely grant it. No single list is exact for many targets; the service is where exactness lives, and the refusal is the mechanism rather than a failure of this one.

The D43 cap is different and is applied, because it is absolute rather than per-target: no key may produce an interactive owner or admin anywhere.

func (*Service) Workspaces

func (s *Service) Workspaces(ctx context.Context, actor *auth.Identity) ([]Workspace, error)

Workspaces lists the workspaces of the caller's organization that they may act in.

The same membership rule the evaluator applies, because it is the same query the switcher uses — an organization-wide membership matches every workspace, a workspace-scoped one matches exactly its own. So this is not "every workspace in the organization": it is every workspace this person has any business seeing, which is the set a management page should offer.

type Workspace

type Workspace struct {
	ID   uuid.UUID `json:"id"`
	Name string    `json:"name"`
	Slug string    `json:"slug"`
	// Current is where this request is acting. Computed against the identity
	// rather than stored, because "current" is a property of the request.
	Current bool `json:"current"`
	// Manageable is whether the caller may rename or delete this one. Under D31
	// a member can hold workspace.write in one workspace and nothing in the next,
	// so this is answered per row rather than once for the page.
	Manageable bool `json:"manageable"`
}

Workspace is one workspace of the caller's organization, as the management page sees it.

Deliberately not auth.Workspace, which is the switcher's shape: that one carries the organization it belongs to because the switcher spans several, and this list never leaves one.

Jump to

Keyboard shortcuts

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