Concepts

Path permissions

Design for server-enforced, path-scoped permissions, plus sparse checkout for large binary-heavy repositories. PBAC answers who may read or change which paths; sparse checkout answers which permitted paths a workspace materializes.

Related: docs/LOCKING.md reuses the same path-resolution rule (locks are write leases per path).


Permission bits

Existing bitmask (internal/domain/permission.go):

BitValueMeaning
PermissionRead1Read tree metadata and content
PermissionWrite2Push changes under the prefix
PermissionLock4Acquire/release locks (future)
PermissionAdmin1 << 16Manage rules and force operations on the prefix
PermissionAllRead|Write|Lock|AdminAdmin bypass mask

Permission.Has(bit) tests a bit; Permission values are OR-ed.


Rule model

Two tables, allow-only rules, directory-prefix matching. No precedence puzzles: rules grant, project defaults grant, and the deepest matching default is the fallback for users without rules.

pbac_rules — per user/group grants

ColumnNotes
user_id / group_idExactly one is set (CHECK)
org_idRule scope; always set
project_idNULL = every project in the org
path_prefix'' = entire project, otherwise a directory prefix
permissionBitmask granted

project_paths_permissions — project default access per folder

ColumnNotes
project_idProject the default belongs to
path_prefixDirectory prefix, unique per project
permissionDefault bitmask for that prefix; 0 = fallback deny

A row makes a folder accessible by default to any authenticated user; rules add permissions on top. permission = 0 (or no row) is the “no access by default” case: it only applies to users who have no matching rule for that path, so a grant on a deeper or shallower prefix still works. This is the fallback deny.

Path matching

Patterns are directory prefixes, not globs (Git cone-mode semantics):

  • Normalized: leading/trailing / trimmed, empty segments collapsed, .. rejected (domain.NormalizePathPrefix).
  • domain.PrefixCovers(prefix, path): '' covers everything; otherwise path equals the prefix or starts with prefix + "/".
  • domain.PrefixCanDescend(prefix, dir): whether any path below dir can match prefix — used to prune manifest traversal without missing deep grants.

Evaluation

resolve(claim, project, path) -> Permission:
  admin/super-admin                    -> PermissionAll
  org owner                            -> PermissionAll
  mask := 0
  for rule in effective_rules(user, project):   # user rules + group rules
      if PrefixCovers(rule.path_prefix, path):
          mask |= rule.permission
  default := longest matching project_paths_permissions row
  if default != nil:
      mask |= default.permission                # fallback deny when 0
  return mask
  • An owner of the project’s organization (org_members.role = 'owner') implicitly holds PermissionAll on every project in that org — the creator of an org can use the projects they create without seeding rules. Plain org members get nothing without rules. Permission.load resolves this per project through pbacRepository.ProjectOrgID + orgAuthorizer.IsOrgOwner (cached with the rest of the rule set, so owner add/demote/remove surfaces at the next TTL expiry).
  • Effective rules are fetched with one query that joins group_members and resolves org scope via the project (pbac.sql, PBACRuleListEffective).
  • HasProjectAccess(project, bit) = admin OR org owner OR any rule/default carries the bit; it gates project-wide operations (list branches, clone).
  • HasPathAccess(project, path, bit) checks resolve.
  • A path with Read denied returns 404 (do not disclose existence); a path readable but not writable returns 403.
  • Results are cached per (project, user) with a TTL and a per-project version counter bumped by every rule mutation.

FilterTree prunes a manifest to permitted paths, keeping file-less “trail” nodes so a grant below a denied directory stays reachable.


Enforcement map

RPCCheck
GetListBranch, GetBranch(ByName), GetDefaultBranchproject read
GetTreeManifestproject read; prune requested paths; hidden path 404
GetCommit, GetCommitLog, WalkCommits, GetMergeBaseprune trees
CreateBranchproject write
RenameBranch, DeleteBranchproject write; project admin for protected branches; the default branch cannot be deleted
SetDefaultBranch, SetBranchProtectionproject admin
MergeFastForwardwrite on every changed path (diff.TreeDiff); a protected target can only be moved by merging a merge request (project admin required for the MR)
Pushwrite per pushed and removed path; protected branches always refuse direct pushes and changes must land through a merge request
GetChunkDownloadUrlshash must be in the permitted chunk set for the given commits and path prefixes; hidden hashes are omitted from the response
GetChunkUploadUrls, ConfirmChunkUploadsproject write
rule/group managementadmin or PermissionAdmin on the prefix

Admin mutations invalidate the project cache.

Chunk authorization

GetChunkDownloadUrls requests carry a project context, the commit IDs whose trees the client is materializing, and optional path prefixes. The server unions the readable chunks of those trees (reusing the manifest walk) and signs URLs only for hashes in that set; anything else is omitted so hidden content cannot be probed by hash. Merge, revert and diff pass every commit involved (target, source, base/parent), because three-way materialization mixes chunks from several trees. GetChunkUploadUrls/ConfirmChunkUploads only need project write, so content can be uploaded before a push that is then validated per path. Signed URLs are capabilities: the /api/chunks HTTP endpoints verify the HMAC signature (project, hash, op, size, expiry) instead of the Bearer token, so a URL works without a session until it expires. Upload URLs bind the exact chunk size and PUTs are size- and BLAKE3-checked; chunk metadata rows are only written by ConfirmChunkUploads after the content exists.

Admin surface

GetMyPermissions is available to any authenticated user. Rule and project default management requires IsAdmin/IsSuperAdmin, an owner of the project’s organization, or a rule granting PermissionAdmin on the whole project (prefix ""); org-wide rules (no project) require a global admin, and the gRPC API currently creates project-scoped rules only. Rule targets are validated: the user must exist and the group must exist and belong to the rule’s organization. Deleting an org-wide rule requires a global admin too, so a project admin cannot remove a rule that governs every project in the org. Group create/list/member management requires a global admin or an owner of the group’s organization (org_members.role = 'owner'). Every rule, default and group-membership change invalidates the permission cache immediately.

The CLI exposes this as nipa acl list|grant|revoke|my, nipa permission list|set|remove and nipa group create|list|add-member|remove-member, using the organization/project of the current working copy.


Sparse checkout

Sparse checkout is a working-copy filter on top of PBAC: PBAC decides what a user may see, sparse paths decide what the client wants to materialize.

  • .nipa/config stores sparse: ["assets/textures", "src"]; absent/empty means a full clone of the permitted tree.
  • GetTreeManifestRequest.paths carries the prefixes; the server returns a tree pruned by permission and sparse prefixes while keeping repo-relative paths.
  • Update/switch reconcile only the sparse set; deselected unmodified files are removed, cached chunks are kept for cheap re-adding.
  • Push sends the pinned base_commit_id (a sparse snapshot hash is not the real head tree hash) and the server rebuilds the new tree from the full base, so files outside the sparse set are never touched. Write is checked per path.
  • CLI: nipa clone <url> <target> --sparse a,b and nipa sparse-checkout list|set|add|remove|disable.
  • Merge and revert still require a full checkout and are rejected in sparse or subdirectory clones; nipa diff works on the filtered trees.
  • The server persists untouched directories as hash references in the new commit (rather than re-materializing them), so incremental and sparse pushes stay proportional to the change and manifests resolve shared directories by content hash.

Roadmap

StepDeliverable
M0Schema + queries + repository + Permission usecase (evaluation, pruning, cache)
M1Manifest/history pruning, paths on GetTreeManifest
M2Authorized GetChunkDownloadUrls, gated GetChunkUploadUrls/ConfirmChunkUploads
M3Per-path Push, fast-forward changed-path check
M4Rule/group management RPCs + nipa acl/group CLI
M5Sparse clone/update/push + sparse-checkout CLI
M6Lazy subtree fetch, web UI ACL admin (Postgres parity: ee/repository/postgres)