Skip to content

Demo (not for merge): is_public enforcement on the git read path — grounds the #18 design decisions#24

Open
beardthelion wants to merge 2 commits into
Gitlawb:mainfrom
beardthelion:feat/private-read-enforcement
Open

Demo (not for merge): is_public enforcement on the git read path — grounds the #18 design decisions#24
beardthelion wants to merge 2 commits into
Gitlawb:mainfrom
beardthelion:feat/private-read-enforcement

Conversation

@beardthelion
Copy link
Copy Markdown

@beardthelion beardthelion commented Jun 1, 2026

Not a merge candidate — a working demonstration to make the open design decisions in #18 concrete instead of theoretical. It wires the is_public flag the node already stores but never checks on the git read path, and runs end to end against a local node. Please read the "What this does NOT do" section before reviewing as a feature.

What works

authorize_read gates the git smart-HTTP read path (info/refs?service=git-upload-pack and git-upload-pack) on the DID that signed the request (verified upstream by optional_signature). Unauthorized reads return 404, not 403, byte-identical to a missing repo.

Caller Repo Result
anonymous private 404 (identical to missing repo)
owner DID private 200
wrong DID private 404
anyone public 200 (unchanged)

A 6-case truth-table unit test pins this, including an assertion that the private-denial payload is indistinguishable from a missing repo. (cargo fmt/clippy clean, rebased on main, no conflicts.)

What this does NOT do (why it is a demo, not a feature)

These are deliberate, and several are genuinely maintainer decisions, not things I should pick unilaterally:

  1. Other read surfaces are still open. authorize_read is wired into the two HTTP handlers only. The IPFS read path (api/ipfs.rs, ipfs_pin.rs) and gossip replication still serve the objects ungated. So a repo marked private here is still readable through those paths. This is the single biggest reason it must not be mistaken for real privacy, and closing those surfaces is the same decision as the replication question below.
  2. Owner-only, no reader sets. Authorization is caller_did == owner_did. There is no way to grant another DID read access, so this expresses "private to me alone," not the capability-based reader sets Path/package-scoped visibility — make "private" a property of a subtree, not a whole repo #18 is actually about.
  3. Whole-repo boolean, not the scoped model. This enforces the existing is_public boolean; it does not add a path-scoped data model. Path/package-scoped visibility — make "private" a property of a subtree, not a whole repo #18 argues the data model should carry path scope from day one to avoid a later migration — this demo deliberately does not, pending your call on that.
  4. No way to create/flip a private repo via gl. The create API accepts is_public:false, but every gl path hard-codes true and there is no endpoint to change visibility after creation. The demo was exercised by hand-crafting the create call.

The decision that unblocks the real feature

@kevincodex1 the fork that gates everything (path-scoping, and surfaces #1 above) is replication of gated content: should a peer lacking authorization (a) never receive the objects in gossip/IPFS at all, or (b) receive hashes/metadata and get capability-required on content fetch? (a) is stricter; (b) is friendlier to the durability goal but leaks existence. The byte-identical-404 in this demo is one expression of (a) on the HTTP path; if you want (b), even this behavior changes. Pick a direction and I will build the path-scoping layer on top.

Also: is private-read / authorization enforcement already claimed or in progress? I would rather build on it than collide.

…e-read)

`repos.is_public` is stored on every repo but never checked when serving
clone/fetch, so private repos are world-readable over git smart-HTTP. This
wires read enforcement for the whole-repo (scope=`/`) case — the literal
"Implement private-read enforcement" short-term roadmap item.

node:
- api/repos.rs: add `authorize_read()`. Public repos stay open; private repos
  require the caller's authenticated DID to match the owner. Returns 404 (not
  403) on denial so a private repo's existence does not leak. Mirrors the
  owner-match idiom in api/protect.rs. Gates `git_upload_pack` and the
  `git-upload-pack` branch of `git_info_refs`; the receive-pack (push)
  handshake is left untouched (authorized separately on the POST).
- server.rs: move `info/refs` into `git_read_routes` and layer
  `optional_signature`, so an `AuthenticatedDid` is available on reads when a
  signature is present, without breaking anonymous clone of public repos.

client (git-remote-gitlawb):
- main.rs: sign the GET ref-advertisement, and broaden POST signing from
  push-only to fetch too, so a `git clone` of a private repo can authenticate.

Out of scope (deliberately): path/package-scoped visibility, the gossip/sync
and GraphQL/IPFS read surfaces, and the never-replicate-vs-fetch-denied
question. See proposal discussion.

Verification: `cargo check --workspace` clean (0 errors, 0 warnings);
git-remote-gitlawb unit tests 6/6 pass.
Six unit tests over the full visibility matrix for `authorize_read`:
public → allow (anonymous and any DID); private → allow owner; private → deny
anonymous; private → deny non-owner. Plus a no-leak contract test asserting the
denial payload is byte-identical to a missing repo (`RepoNotFound("owner/secret")`),
so a private repo's existence cannot be inferred from the response.

Red-green verified: disabling the enforcement check fails exactly the three
denial tests and leaves the three allow tests green.
@beardthelion beardthelion changed the title feat(node): enforce is_public on the git read path (whole-repo private-read) Demo (not for merge): is_public enforcement on the git read path — grounds the #18 design decisions Jun 1, 2026
@kevincodex1
Copy link
Copy Markdown
Contributor

copy

@beardthelion
Copy link
Copy Markdown
Author

No rush on the build-out. Just the one fork when you have a sec: for gated content, should a peer lacking authorization (a) never receive the objects in gossip/IPFS at all, or (b) receive hashes/metadata and get capability-required on content fetch?

Even a one-letter answer unblocks the path-scoping work. Also fine to hear if it is already claimed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants