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:
- Public repository baseline capabilities.
- Personal namespace owner privileges.
- Organization admin privileges.
- 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:
- Create
namespaces. - Create
organizations. - Backfill one user namespace for every profile with a username.
- Add
repositories.owner_namespace_id. - Backfill repository namespace ownership from existing
owner_profile_id. - Make
owner_namespace_idrequired. - 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:
- Create
organization_memberships. - Create
repository_access_grants. - Create
repository_invitations. - Add FKs and indexes for membership, grants, and invites.
0023_repository_owner_audit_metadata.sql
Purpose:
- Drop old owner/slug index.
- Relax
repositories.owner_profile_idfrom required to nullable. - Re-add FK with
ON DELETE SET NULL. - 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:
- Update
repoCapabilities. - Update labels, dependencies, and presets.
- Update settings/action gating if the capability gates UI.
- Update gateway SQL if the capability is used outside the web app.
- Update tests.
- 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:
- During onboarded session loads for the current user.
- 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. |