Docs
Markdown docs from docs.
Organizations and Repository RBAC

Organizations and Repository RBAC

This document maps the current organizations and repository RBAC implementation in open-git. It is written for maintainers who need to understand how URL ownership, Stack Auth teams, cached organization membership, repository grants, gateways, and UI/API authorization fit together.

Executive Summary

The system now separates repository ownership from user profiles by introducing namespaces. A namespace is the canonical URL owner for repositories and can be either a user namespace or an organization namespace.

Organizations are backed by Stack Auth teams. Stack remains the source of truth for team identity, team membership, and team-level organization permissions. open-git stores a local organization row, a namespace row, and a cached membership row so repository authorization can be evaluated quickly by the web app and by standalone gateways.

Repository authorization is capability-based. The web app computes effective access with getRepositoryAccess, which merges:

  1. Public repository baseline capabilities.
  2. Personal namespace owner privileges.
  3. Organization admin privileges.
  4. Direct per-repository grants.

For organization repositories, direct per-repository grants only apply while the user is an active member of the owning organization, unless the user is an org admin. This prevents stale repo grants from continuing to authorize after organization removal.

Source Of Truth Files

Area Files
Schema apps/web/db/schema.ts
Capability vocabulary apps/web/lib/repository-permissions.ts
Access evaluator and grant helpers apps/web/db/repository-permissions.ts
Namespaces and org records apps/web/db/namespaces.ts
Org membership sync apps/web/db/organization-memberships.ts
Repository lookup and listing apps/web/db/repositories.ts
Repository settings access actions apps/web/app/[username]/[repository]/settings/actions.ts
Organization settings actions apps/web/app/[username]/settings/actions.ts
Organization creation apps/web/app/settings/account/teams/new/actions.ts
Repository creation apps/web/app/repositories/new/actions.ts
Onboarding sync apps/web/app/onboarding/ensure-onboarded.ts
Git gateway auth apps/git-gateway/src/db.ts, apps/git-gateway/src/server.ts
Campfire gateway auth apps/campfire-gateway/src/db.ts, apps/campfire-gateway/src/server.ts
Migrations apps/web/drizzle/0021_namespaces_and_organizations.sql, apps/web/drizzle/0022_repository_permissions.sql, apps/web/drizzle/0023_repository_owner_audit_metadata.sql
Tests apps/web/lib/repository-permissions.test.ts
Stack reference docs ref_docs/stack-teams.md, ref_docs/stack-teams-permissions.md, ref_docs/stack-teams-and-perms-api.md

Terms

Term Meaning
User profile open-git's local record for a Stack user, stored in user_profiles.
Namespace URL owner identity stored in namespaces; can represent a user or an org.
User namespace A namespace where kind = "user" and user_profile_id points to a user profile.
Organization namespace A namespace where kind = "org" and an organizations row points back to it.
Organization open-git's local mirror of a Stack team, stored in organizations.
Stack team Stack Auth team that backs one open-git organization.
Org membership Cached Stack membership stored in organization_memberships.
Org capability Cached team-level capability such as org.admin.
Repo capability Fine-grained repository capability such as repo.git.write.
Direct repo grant Per-user repository grant stored in repository_access_grants.
Repo invitation Email-based invitation for personal repositories, stored in repository_invitations.
Public baseline Automatic capability set given to users for public repositories.

Identity And Ownership Model

Repository URLs use namespaces, not user profiles.

/:namespaceSlug/:repositorySlug

The namespace slug is globally unique across users and organizations.

user_profiles.username
        |
        | user namespace
        v
namespaces(kind = "user", slug, user_profile_id)
        |
        | owns
        v
repositories(owner_namespace_id, slug)

Stack team
        |
        | mirrored by
        v
organizations(stack_team_id, namespace_id)
        |
        | org namespace
        v
namespaces(kind = "org", slug)
        |
        | owns
        v
repositories(owner_namespace_id, slug)

Important Ownership Invariant

repositories.ownerNamespaceId is the authority for ownership and URLs.

repositories.ownerProfileId is no longer the owner authority. It is nullable audit metadata for the profile that created the repository. Code that needs the repository owner should use ownerNamespaceId or helpers that join through namespaces.

Owner Resolution

getRepositoryByOwnerAndSlug(ownerUsername, slug) joins repositories to namespaces:

repositories.owner_namespace_id = namespaces.id
namespaces.slug = ownerUsername
repositories.slug = slug

It returns repository data plus:

Field Source
ownerUsername namespaces.slug
ownerDisplayName namespaces.displayName
ownerKind namespaces.kind

The name ownerUsername is legacy naming. For org-owned repositories it is the organization namespace slug, not a user username.

Database Model

namespaces

Canonical owner identity for URLs and repository ownership.

Column Purpose
id Primary key.
slug Globally unique URL slug.
kind "user" or "org".
user_profile_id Set for user namespaces; null for org namespaces.
display_name Display name copied from profile or organization.
avatar_url Avatar copied from profile or organization.
created_at, updated_at Timestamps.

Indexes:

Index Purpose
namespaces_slug_idx Global slug uniqueness.
namespaces_user_profile_id_idx One user namespace per profile.
namespaces_kind_idx Fast user/org filtering.

organizations

Local open-git organization record linked to a Stack team.

Column Purpose
id Primary key.
namespace_id Required link to namespaces.
stack_team_id Required link to Stack Auth team.
display_name Organization display name.
avatar_url Organization avatar.
created_by_profile_id Audit link to creating profile; set null if profile is deleted.
created_at, updated_at Timestamps.

Indexes:

Index Purpose
organizations_namespace_id_idx One org per namespace.
organizations_stack_team_id_idx One org per Stack team.
organizations_created_by_profile_idx Find orgs created by a profile.

organization_memberships

Local cache of Stack team membership and derived org capabilities.

Column Purpose
id Primary key.
organization_id Owning open-git organization.
stack_user_id Stack user ID.
user_profile_id Local profile if the Stack user has an open-git profile.
display_name, avatar_url Cached member presentation data.
capabilities JSON array of org capabilities.
is_active Whether the user is currently considered a member.
last_synced_at Last sync timestamp.
created_at, updated_at Timestamps.

Indexes:

Index Purpose
organization_memberships_org_stack_user_idx One membership per org and Stack user.
organization_memberships_org_profile_idx Find a profile's membership in one org.
organization_memberships_profile_idx Find all memberships for a profile.
organization_memberships_org_active_idx List active members.

repositories

Repository ownership now points to namespaces.

Column Purpose
owner_namespace_id Required owner namespace. This is the ownership authority.
owner_profile_id Nullable creator audit metadata.
slug Repository slug, unique within owner namespace.
storage_id Storage identifier, currently ${namespace.slug}/${repo.slug}.
visibility "public" or "private".
default_branch Default branch name.
docs_enabled, docs_directory Docs settings.
campfire_enabled Campfire feature flag.
forked_from_repository_id Optional source repository.

Indexes:

Index Purpose
repositories_owner_namespace_slug_idx Unique repository slug inside namespace.
repositories_storage_id_idx Unique storage backing repo.
repositories_owner_profile_idx Audit and legacy lookup support.

repository_access_grants

Direct per-user repository grants.

Column Purpose
repository_id Repository being granted.
user_profile_id User receiving access.
capabilities JSON array of repo capabilities.
granted_by_profile_id Profile that granted access; set null if deleted.

Important behavior:

Rule Detail
One grant per repo/user Enforced by repository_access_grants_repo_user_idx.
Grants are upserted grantRepositoryAccess overwrites capabilities for the repo/user pair.
Capabilities are normalized normalizeRepoCapabilities filters unknown values and expands dependencies.
Org grants require active membership The access evaluator ignores org repo grants for inactive or non-members.

repository_invitations

Email invitations for repository access.

Column Purpose
repository_id Repository being invited to.
email Normalized email address.
capabilities Capabilities to grant on accept.
invited_by_profile_id Inviting profile.
accepted_by_profile_id Profile that accepted.
accepted_at Acceptance timestamp.
revoked_at Revocation timestamp.

Important behavior:

Rule Detail
Personal repos can use email invitations Pending invitations are accepted during onboarding.
Org repos cannot use email invitations Users must be invited to the organization first, then granted repo access.
Existing pending invite is updated Same repo/email pending invite is updated instead of duplicated.

Capability Vocabulary

Repo capabilities live in apps/web/lib/repository-permissions.ts.

Repository Capabilities

Capability Label What It Gates
repo.view View repository Repository pages, blob/docs/discussions/insights, issue/PR visibility, CI logs.
repo.git.read Clone and fetch Git gateway read access and fork source access.
repo.git.write Push branches Git gateway writes and PR source repository eligibility.
repo.issue.create Create issues Issue creation and issue comments.
repo.issue.manage Manage issues Issue state/metadata management; issue authors can still manage their own issue if they can view.
repo.pull.create Create pull requests PR creation on target repository.
repo.pull.review Review pull requests PR review actions and review UI.
repo.pull.manage Manage pull requests PR management by non-authors.
repo.pull.merge Merge pull requests Merge action.
repo.campfire.write Post in Campfire Campfire page access for signed-in users, websocket token, gateway write.
repo.settings.manage Manage settings Repository settings and Campfire channel management.
repo.permissions.manage Manage access Repository grants and invitations.
repo.ci.manage Manage CI Runner registration tokens and CI management actions.
repo.delete Delete repository Repository deletion.

Repository Capability Dependencies

Capabilities are dependency-expanded in the web app before storage or evaluation.

Capability Adds
repo.git.read repo.view
repo.git.write repo.view, repo.git.read
repo.issue.create repo.view
repo.issue.manage repo.view, repo.issue.create
repo.pull.create repo.view, repo.git.read
repo.pull.review repo.view
repo.pull.manage repo.view, repo.pull.review
repo.pull.merge repo.view, repo.pull.review
repo.campfire.write repo.view
repo.settings.manage repo.view
repo.permissions.manage repo.view
repo.ci.manage repo.view
repo.delete repo.view

Repository Presets

Presets are UI-level groupings for common access levels.

Preset Capabilities
read repo.view, repo.git.read
participate repo.view, repo.git.read, repo.issue.create, repo.pull.review, repo.campfire.write
write repo.view, repo.git.read, repo.git.write, repo.issue.create, repo.pull.create, repo.pull.review, repo.campfire.write
maintain repo.view, repo.git.read, repo.git.write, repo.issue.create, repo.issue.manage, repo.pull.create, repo.pull.review, repo.pull.manage, repo.pull.merge, repo.campfire.write, repo.settings.manage, repo.ci.manage
admin Every repo capability.

Public Baselines

Public repositories automatically grant baseline capabilities.

Viewer Baseline
Anonymous read preset: repo.view, repo.git.read.
Signed-in participate preset plus repo.pull.create.

Signed-in public users can create issues, create pull requests, review pull requests, and post in Campfire if Campfire is enabled. They cannot push branches, change settings, manage permissions, manage CI, merge PRs, or delete repositories unless another rule grants those capabilities.

Organization Capabilities

Org capabilities are cached in organization_memberships.capabilities.

Capability Meaning
org.member User is a member of the org.
org.admin User is an org admin. In repo access evaluation, this grants all repo capabilities on org-owned repos.
org.create_repositories User can create repositories in the org namespace.
org.manage_repositories User can manage repository creation/ownership operations for the org.

Stack Permission Mapping

Stack Auth remains the source of truth for team permissions.

Stack Permission open-git Capability Or Behavior
$update_team Admin fallback; maps to org.admin during sync and allows org repo creation.
$read_members Allows member list visibility in organization settings.
$remove_members Required to remove an organization member.
org:create_repositories Maps to org.create_repositories; allows org repo creation.
org:manage_repositories Maps to org.manage_repositories; allows org repo creation.

Access Evaluator

The web app's central access evaluator is:

getRepositoryAccess(profile, repository) -> RepositoryAccess

Return shape:

Field Meaning
capabilities Effective repo capabilities after all sources are merged.
canViewSettings True if any settings-related capability exists.
directGrantCapabilities Capabilities from the direct grant only.
isDirectGrant True if a direct grant row contributed capabilities.
isOrgAdmin True if active org membership includes org.admin.
isPersonalOwner True if repo is owned by the viewer's user namespace.
isPublicBaseline True if public baseline was applied.
organizationId Owning organization ID for org repos, otherwise null.

Evaluation Order

The implementation effectively evaluates access like this:

start with empty capability set

if repository.visibility == "public":
  if profile exists:
    add signed-in public baseline
  else:
    add anonymous public baseline

load owner namespace and linked organization, if any

if no profile:
  return public baseline result

if namespace.kind == "user" and namespace.user_profile_id == profile.id:
  mark personal owner
  add all repo capabilities

if repository is org-owned:
  load active organization membership for profile
  if membership capabilities include org.admin:
    mark org admin
    add all repo capabilities

directGrantApplies =
  repository is not org-owned
  OR profile has active org membership
  OR profile is org admin

if directGrantApplies:
  load repository_access_grants row for repo/profile
  normalize and dependency-expand capabilities
  add them to the effective set

return sorted/structured access result

Access Source Precedence

There is no deny rule. Effective access is the union of all applicable allow sources.

Source Applies When Effect
Public baseline Repository is public. Adds anonymous or signed-in baseline.
Personal owner User namespace owner matches profile. Adds all repo capabilities.
Org admin Active org membership includes org.admin. Adds all repo capabilities.
Direct grant Personal repo, or active org member, or org admin. Adds grant capabilities.

Because there is no deny rule, a user can retain access through public baseline even if a direct grant is removed. Removing a grant only removes the capabilities that came from that grant.

Namespace Flows

User Onboarding And User Namespace Creation

Entrypoints:

Step Code
Load Stack user getOnboardingStatus
Upsert local profile upsertUserProfile
Set username setUserProfileUsername
Create or update user namespace setUserProfileUsername, ensureUserNamespace
Sync org memberships syncCurrentUserOrganizationMemberships
Accept pending repo invitations acceptPendingRepositoryInvitationsForUser

Flow:

Stack user signs in
  -> upsert user_profiles by stack_user_id
  -> if username exists:
       sync current user's Stack team memberships
       accept pending repository invitations for primary email
       ensure Stack client metadata reflects onboarding state
  -> if username missing:
       redirect to onboarding

When a username is selected:

setUserProfileUsername(user, username)
  -> upsert user profile
  -> transaction:
       check namespaces.slug for collision
       update user_profiles.username
       insert/update namespaces(kind = "user", user_profile_id = profile.id)

Collision behavior:

Condition Outcome
Namespace slug exists for another profile or org Throw unique-like 23505.
Namespace slug exists for same profile Allowed; namespace is updated.
Legacy user_profiles.username collision Checked by availability helpers during onboarding.

Organization Creation

Entrypoint:

createOrganizationFromForm

Flow:

ensure user is onboarded
  -> normalize display name and slug
  -> validate slug using namespace/user slug rules
  -> check global namespace availability
  -> create Stack team
  -> write openGitNamespaceSlug into Stack metadata
  -> create local namespace(kind = "org")
  -> create local organizations row with stack_team_id
  -> create creator membership with:
       org.member
       org.admin
       org.create_repositories
       org.manage_repositories
  -> redirect to /:orgSlug

Failure cleanup:

Failure Point Cleanup
Local DB creation fails after Stack team creation Attempts team.delete().
Slug unique violation Returns "That organization URL is already taken."
Unknown failure Returns generic creation failure.

Organization Membership Sync

Two sync paths exist:

Sync Function When Used Scope
syncCurrentUserOrganizationMemberships On onboarded session load. Current user's teams.
syncOrganizationMembershipsFromStackTeam Organization settings page/actions. Full Stack team roster.

Current-user sync:

user.listTeams()
  -> find local organizations with matching stack_team_id
  -> for each linked org:
       derive capabilities from Stack permissions
       upsert active organization_memberships row
  -> deactivate this Stack user's memberships for orgs no longer returned
  -> revoke org repository grants for deactivated local profile IDs

Full org sync:

stackServerApp.getTeam(organization.stackTeamId)
  -> team.listUsers()
  -> update local organization and namespace display metadata
  -> match Stack users to local user_profiles by stack_user_id
  -> upsert active memberships for returned users
  -> deactivate memberships for users no longer in Stack team
  -> revoke org repository grants for deactivated local profile IDs

Permission derivation:

start with org.member
if Stack user has $update_team:
  add org.admin
if Stack user has org:create_repositories:
  add org.create_repositories
if Stack user has org:manage_repositories:
  add org.manage_repositories

Organization Settings

Entrypoints:

Page/Action Behavior
app/[username]/settings/page.tsx Shows org overview/settings for active members.
removeOrganizationMemberFromForm Requires Stack $remove_members; removes user from Stack team, deactivates local member, syncs.
leaveOrganizationFromForm Current user leaves Stack team, local membership is deactivated, redirects to account settings.
syncOpenGitOrganizationFromStack Re-syncs local org cache from Stack.

Settings visibility:

Viewer Outcome
Not an active org member 404.
Active member Can view org settings overview.
Active member with $read_members or org.admin Can view member list.
User with $remove_members Can remove other members.
Active member Can leave org.

Repository Flows

Repository Creation

Entrypoint:

createRepositoryFromForm

Flow:

ensure user is onboarded
  -> normalize repo slug, description, visibility
  -> require ownerNamespaceId from form
  -> validate repo slug
  -> canCreateRepositoryInNamespace(namespaceId, profileId, user)
  -> check repo slug uniqueness inside namespace
  -> storageId = `${namespace.slug}/${repoSlug}`
  -> insert repositories:
       ownerNamespaceId = namespace.id
       ownerProfileId = creator profile id
       slug, description, visibility, storageId, defaultBranch
  -> create backing code-storage repo
  -> optionally create README initial commit
  -> index repo immediately or enqueue index job
  -> redirect to /:namespaceSlug/:repoSlug

Create permission by namespace kind:

Namespace Kind Required Condition
User namespace.userProfileId === profile.id.
Org Stack team exists and current Stack user has org:create_repositories, org:manage_repositories, or $update_team.

Storage note:

storageId includes the namespace slug. Org slug renames are disabled in the UI because storage IDs currently use slugs.

Repository Read Pages

Most repository pages follow this pattern:

getRepositoryByOwnerAndSlug(username, repository)
  -> getOnboardingStatus()
  -> getRepositoryAccess(profile, repository)
  -> require repo.view
  -> render or 404

Examples:

Surface Required Capability
Repository home repo.view
Blob pages repo.view
Docs repo.view
Discussions repo.view
Insights repo.view
Issues list/detail repo.view
Pull requests list/detail repo.view
CI job logs repo.view

Repository Settings

Repository settings use separate capabilities for separate actions.

Action Required Capability
View settings page repo.view; individual panels depend on more specific caps.
Update repository settings repo.settings.manage
Manage Campfire channels repo.settings.manage
Delete repository repo.delete
Create runner registration token repo.ci.manage
Grant/revoke user access repo.permissions.manage
Create/revoke email invitations repo.permissions.manage

Settings page refresh behavior:

If the viewer can manage permissions on an org repo, the settings page attempts a full Stack team sync before rendering grants. It then recomputes repository access. This keeps cached org memberships close to Stack before access is managed.

Repository Grants

Grant flow:

grantRepositoryAccessFromForm
  -> ensure onboarded
  -> resolve repository by namespace slug and repo slug
  -> require repo.permissions.manage
  -> findRepositoryGrantTarget(identifier, repository)
  -> derive capabilities from preset and explicit checkboxes
  -> normalize and expand capabilities
  -> upsert repository_access_grants row
  -> revalidate settings page

Grant target rules:

Repository Owner Target Rule
User namespace Target must be an existing user profile.
Org namespace Target must be an existing active member of the owning organization.

Direct grant applicability at access-check time:

Repository Owner Viewer State Does Direct Grant Apply?
User namespace Any profile with grant Yes.
Org namespace Active org member with grant Yes.
Org namespace Org admin Yes, but admin already has all repo capabilities.
Org namespace Not an active member No, even if a grant row exists.
Org namespace Inactive member No, and sync/deactivation attempts to revoke grants.

Repository Invitations

Invitation flow for personal repositories:

createRepositoryInvitationFromForm
  -> require repo.permissions.manage
  -> reject if repository is org-owned
  -> normalize email
  -> normalize capabilities
  -> create or update pending repository_invitations row

on onboarded session load:
  -> acceptPendingRepositoryInvitationsForUser(primaryEmail, profileId)
  -> find unaccepted/unrevoked invitations by normalized email
  -> grant repository access to this profile
  -> mark invitations accepted

Org-owned repositories intentionally reject email invitations. The intended flow is:

invite user to Stack team
  -> sync org membership into open-git
  -> grant repository access to active org member

Issues

Surface/Action Required Capability
View issue list/detail repo.view
Create issue repo.issue.create
Comment on issue repo.issue.create
Manage issue Issue author with repo.view, or any user with repo.issue.manage
Link pull request from issue action Requires visibility to referenced PR repository through repo.view

Pull Requests

Surface/Action Required Capability
View PR list/detail repo.view on target, and sensitive API routes also require source visibility.
Open PR repo.pull.create on target repository.
Choose PR source repo repo.git.write on source repository; private cross-repo sources are excluded.
Manage PR PR author with source/target visibility, or repo.pull.manage on target.
Review PR repo.pull.review on target and visibility to source/target.
Merge PR repo.pull.merge on target and visibility to source/target.

canViewPullRequestRepositories requires repo.view on both source and target repositories. API routes for PR JSON/files/diffs use it to avoid leaking private source repository data.

Forks

Forking requires repo.git.read on the source repository. Public repositories grant this to anonymous users as a read baseline, but the fork action itself requires onboarding because a fork needs an owner namespace.

CI

Surface/Action Required Capability
View CI job page/logs repo.view
Create runner registration token repo.ci.manage
DB helper canManageCiForJob repo.ci.manage

Campfire

Web app behavior:

Surface/Action Required Capability
View Campfire page repo.view; signed-in users must also have repo.campfire.write.
Post in Campfire repo.campfire.write.
Create websocket token repo.campfire.write.
Ref search/details repo.view, and signed-in requests also require repo.campfire.write.
Manage Campfire channels repo.settings.manage.

Gateway behavior:

The Campfire gateway uses raw SQL and checks:

if repository is public:
  allow authenticated token holder to write
else if personal owner:
  allow
else if org admin:
  allow
else if org repo and not active member:
  deny
else if direct grant includes repo.campfire.write:
  allow
else:
  deny

The gateway only receives authenticated websocket/session context, so public repository write means "any authenticated user", not anonymous write.

Gateway Authorization

Gateways cannot import the web app's Drizzle helpers, so they reimplement the relevant checks in SQL.

Git Gateway

Repository resolution:

resolveRepository(sql, ownerUsername, slug)
  -> join repositories to namespaces
  -> namespaces.slug = ownerUsername
  -> repositories.slug = slug

Token authentication:

authenticateGitToken(sql, username, token)
  -> sha256(token)
  -> match user_profiles.username
  -> match git_access_tokens.token_hash
  -> require not revoked
  -> require not expired
  -> update last_used_at

Read/write checks:

Git Operation Token Scope Repo Capability
Read/fetch/clone private repo repo:read repo.git.read
Read/fetch/clone public repo None required for public baseline repo.git.read implied by public visibility
Write/push repo:write repo.git.write

SQL capability logic:

if repository.visibility == "public" and capability == "repo.git.read":
  allow
if no actor:
  deny
load namespace and organization
if user namespace owner:
  allow
if active org member with org.admin:
  allow
if org-owned and actor is not active org member:
  deny
if direct grant JSON contains exact capability:
  allow
else:
  deny

Important gateway caveat:

The gateway checks JSONB for the exact capability string. Web-created grants are normalized and dependency-expanded before storage, so implied capabilities should be present. Manually inserted grants that omit dependencies may behave differently in the gateway than in the web evaluator.

Campfire Gateway

The Campfire gateway mirrors the relevant subset of repository access for posting messages. It checks public visibility, personal ownership, active org admin status, active org membership, and direct repo.campfire.write grants.

It also resolves mentioned issues and PRs by joining referenced repositories through namespaces, so #123 style references across org/user namespaces resolve against namespace slugs rather than user profile usernames.

UI Mapping

Account Settings

UI Area Behavior
Account profile User namespace mirrors username/display/avatar.
Teams list Stack teams can link to open-git organizations.
New team/org page Creates Stack team plus local organization namespace.
Legacy team route Redirects linked Stack team settings to /:namespace/settings.

Owner Pages

URL Owner Type Behavior
/:username User namespace Shows profile identity and visible repositories.
/:orgSlug Org namespace Shows org identity and visible repositories.
/:orgSlug/settings Org namespace Shows org settings for active org members.

Repository listing behavior:

Viewer Personal Namespace Page Org Namespace Page
Anonymous Public repositories only. Public repositories only.
Signed-in non-owner Public repositories plus directly granted private repos. Public repositories only when not an active org member.
Personal owner All repos in own namespace. Not applicable.
Org admin All repos in org namespace. All repos in org namespace.
Org member Public repos plus directly granted private repos. Public repos plus directly granted private repos.

Repository Settings Panels

The settings page computes one RepositoryAccess object and passes booleans to forms:

Boolean Source Capability
canManageSettings repo.settings.manage
canManagePermissions repo.permissions.manage
canManageCi repo.ci.manage
canDelete repo.delete

canViewSettings is derived from any of:

repo.settings.manage
repo.permissions.manage
repo.ci.manage
repo.delete

Migration Sequence

0021_namespaces_and_organizations.sql

Purpose:

  1. Create namespaces.
  2. Create organizations.
  3. Backfill one user namespace for every profile with a username.
  4. Add repositories.owner_namespace_id.
  5. Backfill repository namespace ownership from existing owner_profile_id.
  6. Make owner_namespace_id required.
  7. Add namespace/org/repository ownership indexes.

Backfill mapping:

user_profiles.username -> namespaces.slug
user_profiles.id -> namespaces.user_profile_id
repositories.owner_profile_id -> namespaces.user_profile_id
repositories.owner_namespace_id -> namespaces.id

0022_repository_permissions.sql

Purpose:

  1. Create organization_memberships.
  2. Create repository_access_grants.
  3. Create repository_invitations.
  4. Add FKs and indexes for membership, grants, and invites.

0023_repository_owner_audit_metadata.sql

Purpose:

  1. Drop old owner/slug index.
  2. Relax repositories.owner_profile_id from required to nullable.
  3. Re-add FK with ON DELETE SET NULL.
  4. Add repositories_owner_profile_idx.

End-To-End Flow Examples

Flow: Create An Organization Repository

User opens /repositories/new
  -> page calls listRepositoryOwnerNamespacesForUser
  -> ensure personal user namespace exists
  -> list Stack teams for user
  -> find linked open-git organizations
  -> call canCreateRepositoryInNamespace for each org namespace
  -> show personal namespace and creatable org namespaces

User submits create form with ownerNamespaceId = org namespace
  -> createRepositoryFromForm
  -> canCreateRepositoryInNamespace checks live Stack permissions
  -> insert repository with ownerNamespaceId = org namespace id
  -> ownerProfileId = creator profile id
  -> storageId = orgSlug/repoSlug
  -> create code-storage repository
  -> redirect to /orgSlug/repoSlug

Flow: Grant Write Access To An Org Repo

Org admin opens /org/repo/settings
  -> getRepositoryAccess sees org.admin
  -> repo.permissions.manage is present
  -> settings page syncs organization membership from Stack
  -> admin enters target username and Write preset

grantRepositoryAccessFromForm
  -> require repo.permissions.manage
  -> findRepositoryGrantTarget
       -> target profile must exist
       -> target profile must be active member of owning org
  -> capabilitiesForPreset("write")
  -> normalizeRepoCapabilities
  -> upsert repository_access_grants row

Target user next accesses repo
  -> getRepositoryAccess sees active org membership
  -> direct grant applies
  -> capabilities include write preset

Flow: Remove A User From An Organization

Org admin removes user in /org/settings
  -> removeOrganizationMemberFromForm requires Stack $remove_members
  -> team.removeUser(stackUserId)
  -> deactivateOrganizationMember locally
  -> revokeOrganizationRepositoryGrants for that profile
  -> syncOrganizationMembershipsFromStackTeam
  -> revalidate org pages

Removed user accesses private org repo
  -> getRepositoryAccess sees no active membership
  -> directGrantApplies is false
  -> no private baseline
  -> repo.view missing
  -> 404

Flow: Public External Contribution

Signed-in user opens public repo
  -> public signed-in baseline applies
  -> capabilities include repo.issue.create and repo.pull.create
  -> user can create issue or PR
  -> user cannot push to the target repo without repo.git.write

For PR creation
  -> target requires repo.pull.create
  -> source repository requires repo.git.write
  -> user usually selects their fork/source repo

Flow: Git Clone And Push

git clone /org/private-repo
  -> gateway resolves repo through namespaces.slug
  -> public read shortcut does not apply
  -> token auth must succeed with repo:read
  -> actorHasRepositoryCapability(..., repo.git.read)
       -> personal owner, org admin, or exact direct grant allows

git push /org/private-repo
  -> token auth must succeed with repo:write
  -> actorHasRepositoryCapability(..., repo.git.write)
       -> personal owner, org admin, or exact direct grant allows

Flow: Pending Personal Repo Invitation

Owner invites email to personal repo
  -> repository_invitations row is created

Invitee signs in and completes onboarding
  -> getOnboardingStatus sees onboarded profile
  -> acceptPendingRepositoryInvitationsForUser(primaryEmail, profile.id)
  -> grantRepositoryAccess creates direct grant
  -> invitation is marked accepted

Edge Cases And Expected Outcomes

Ownership And Slugs

Edge Case Expected Outcome
User tries to choose username matching an org namespace slug. Username is rejected because namespace slugs are global.
User tries to create org slug matching existing user namespace. Org creation is rejected.
User namespace exists but username changes. User namespace is updated by setUserProfileUsername or ensureUserNamespace. Existing repository storageIds may still contain old slug if storage renaming is not handled.
Org wants to rename slug. UI says slug renames are disabled because repository storage IDs use namespace slugs.
Repository lookup receives an org slug in a param named username. Works; helpers join through namespaces.slug.
Code uses ownerProfileId as owner authority for org repos. This is wrong for org repos; ownerProfileId is only creator audit metadata.

Public And Private Visibility

Edge Case Expected Outcome
Anonymous viewer opens public repo page. Gets repo.view and can read.
Anonymous viewer opens private repo page. No baseline, no profile, 404.
Signed-in non-member opens public org repo. Gets signed-in public baseline.
Signed-in non-member opens private org repo with stale grant row. Grant is ignored because active org membership is required.
Public repo grant is revoked. User may still retain public baseline capabilities.
Public repo is changed to private. Public baseline disappears; only owner/admin/direct grants remain.

Organization Membership

Edge Case Expected Outcome
Stack listTeams() fails during current-user sync. Local memberships for that Stack user are deactivated.
Stack getTeam() or listUsers() fails during full org sync. Local memberships for that organization are deactivated.
Member exists in Stack but has no open-git profile. Membership row can exist with userProfileId = null; repo grants cannot target them until they have a profile.
Member is removed from Stack team. Local membership is deactivated on sync and org repo grants for the local profile are revoked.
Member loses $update_team. Next sync removes org.admin; repo access falls back to grants/public baseline.
Member loses org:create_repositories. Next live create check or sync prevents org repository creation unless another allowed Stack permission remains.

Direct Grants

Edge Case Expected Outcome
Grant has unknown capability strings. Web normalization filters them out.
Grant only stores repo.git.write manually, without dependencies. Web normalization expands dependencies at read time; gateway exact JSONB checks may only see repo.git.write.
Grant is created through helper with repo.git.write. Stored capabilities include dependency-expanded values, so gateway read/write checks work.
Grant capabilities become empty after normalization. grantRepositoryAccess returns null and does not create a useful grant.
Org repo grant target is not an active org member. Form returns "Invite this user to the organization before granting repo access."
Org member is deactivated but grant revocation fails or grant remains. Access evaluator still ignores the grant because active membership is required.

Invitations

Edge Case Expected Outcome
Invite email has uppercase or spaces. Email is normalized with trim/lowercase.
Same repo/email receives another pending invite. Pending invite is updated with new capabilities/inviter.
Invitation is revoked before onboarding. Acceptance ignores it because revokedAt is set.
Invitation has already been accepted. Acceptance ignores it because acceptedAt is set.
Org repo tries to create email invitation. Rejected; user must be invited to org first.
Invitee signs in with a different primary email. Pending invitation is not accepted.

Pull Requests

Edge Case Expected Outcome
User can view target repo but not source repo. PR API routes that use canViewPullRequestRepositories hide data.
User can create PR on target but cannot push to selected source. PR creation rejects source repository.
Source repo is private and cross-repository. listRepositoriesForPullRequestSource excludes it.
PR author loses target repo visibility. Author-only management still requires source/target visibility checks.
User has repo.pull.merge but source is private and hidden. Merge action is denied by canViewPullRequestRepositories.

Gateways

Edge Case Expected Outcome
Anonymous clone of public repo. Git gateway allows repo.git.read for public repo without actor.
Anonymous push to public repo. Denied; write requires authenticated actor, token scope, and repo.git.write.
Authenticated Campfire post to public repo. Campfire gateway allows if websocket/session auth exists.
Org admin pushes to private org repo without direct grant. Allowed by org admin shortcut.
Active org member has direct repo.git.write grant. Push allowed if token has repo:write.
Inactive org member has direct repo.git.write grant row. Push denied because direct grants do not apply.
Token lacks required scope but repo capability exists. Git gateway denies because token scope and repo capability are both required.

Settings And Destructive Actions

Edge Case Expected Outcome
User has repo.settings.manage but not repo.permissions.manage. Can update repo settings but cannot grant/revoke access.
User has repo.permissions.manage but not repo.settings.manage. Can manage access; settings forms should not allow general settings changes.
User has repo.ci.manage only. Can create runner registration token and view settings area as needed.
User has repo.delete only. Can delete repo and view settings area as needed.
Delete confirmation does not match repo slug. Delete action rejects.

Implementation Notes And Risks

Gateways Duplicate Access Logic

The Git and Campfire gateways reimplement access checks in raw SQL. This is necessary because they are separate apps, but it means any future change to getRepositoryAccess must be mirrored in gateway SQL if it affects git or Campfire behavior.

Checklist for future capability changes:

  1. Update repoCapabilities.
  2. Update labels, dependencies, and presets.
  3. Update settings/action gating if the capability gates UI.
  4. Update gateway SQL if the capability is used outside the web app.
  5. Update tests.
  6. Check migrations/seed data if stored grants need new defaults.

Direct Grant Dependency Expansion

The web helper expands dependencies before storing grants. The gateway checks JSONB for exact capability strings. Keep using grantRepositoryAccess and normalizeRepoCapabilities for all grant writes.

Organization Membership Cache Drift

Stack is the authority, but open-git uses cached membership rows for repo auth. The cache is refreshed:

  1. During onboarded session loads for the current user.
  2. During org settings views/actions for the full organization.

If Stack changes happen out of band, cached rows may lag until one of those sync points runs. Deactivation paths are conservative: if Stack team listing fails, local memberships may be deactivated. Normal member removal and successful sync deactivation paths also revoke org repo grants when a local profile is known. In all cases, inactive memberships cause org repo grants to be ignored by the access evaluator.

Public Baseline Is Broad For Signed-In Users

Signed-in users get repo.issue.create, repo.pull.create, repo.pull.review, and repo.campfire.write on public repositories. This is a product decision encoded in signedInPublicCapabilities.

Legacy Owner Helpers Need Care

Most owner resolution now uses namespaces. Any helper that still accepts ownerProfileId should be reviewed before use with org repositories. In particular, ownerProfileId cannot answer "who owns this repository?" for org repos.

Test Scenarios To Keep Covered

Unit Tests

Existing coverage:

Test File Coverage
apps/web/lib/repository-permissions.test.ts Capability dependency expansion and preset normalization.

Recommended additional unit/integration cases:

Scenario Expected Assertion
Public anonymous access repo.view and repo.git.read, no write/settings caps.
Public signed-in access Participate baseline plus repo.pull.create.
Personal owner All repo capabilities.
Org admin All repo capabilities on org repo.
Active org member with direct grant Gets grant capabilities.
Non-member with direct org grant row Grant ignored.
Inactive member with direct org grant row Grant ignored.
Grant normalization Unknown strings removed, dependencies expanded.
Org removal Membership inactive and org repo grants revoked.
Invitation acceptance Pending personal repo invite creates grant and marks accepted.
Org invitation rejection Org repo email invite action returns error.

Manual Flow Checks

Flow Check
Create org Stack team exists, namespace exists, org row exists, creator membership has admin/create/manage caps.
Create org repo Repo owner namespace is org namespace; storage ID uses org slug.
Grant org repo access Only active org members are targetable.
Remove org member Member cannot view private org repo afterward.
Clone private org repo Requires token scope and repo.git.read.
Push private org repo Requires token scope and repo.git.write.
Public contribution Signed-in non-member can create issue/PR but cannot push.
PR private source User without source visibility cannot fetch PR diff/files via API.

Quick Reference: Capability To Surface

Capability Primary Surfaces
repo.view Repository pages, docs, blob, issues/PR views, CI logs, dashboard/profile visibility.
repo.git.read Git clone/fetch, fork source access.
repo.git.write Git push, PR source repository selection.
repo.issue.create New issue, issue comments.
repo.issue.manage Manage issues as non-author.
repo.pull.create New PR on target repo.
repo.pull.review PR reviews.
repo.pull.manage Manage PR as non-author.
repo.pull.merge Merge PR.
repo.campfire.write Campfire posting and websocket token.
repo.settings.manage Repo settings, Campfire channel settings.
repo.permissions.manage Repository grants and invitations.
repo.ci.manage Runner registration and CI management.
repo.delete Delete repository.

Quick Reference: Flow Decisions

Question Source Of Truth
Who owns this URL namespace? namespaces.slug and namespaces.kind.
Which namespace owns a repository? repositories.owner_namespace_id.
Who created a repository? repositories.owner_profile_id.
Is a user in an org? Active organization_memberships row, synced from Stack.
Is a user an org admin? Active membership with org.admin, derived from Stack $update_team.
Can user create org repos? Live Stack check for org:create_repositories, org:manage_repositories, or $update_team.
Can user view repo? getRepositoryAccess(...).capabilities includes repo.view.
Can user push? Git token scope plus gateway repo.git.write check.
Can user manage access? repo.permissions.manage.
Can direct org repo grant apply? Only if active org member or org admin.
Can email invitation grant org repo access? No. Invite to org first, then grant repo access.