From b525a62b754afa38cc1aee0995eddae5f8a37214 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Tue, 18 Aug 2026 12:20:10 +0300 Subject: [PATCH] authn: name the grant scope dolt.sr.ht/DATABASES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api-meta.json published the scope as lowercase "repos" while every other service on the instance publishes it upper case — git.sr.ht/REPOSITORIES, todo.sr.ht/TRACKERS, paste.sr.ht/PASTES, builds.sr.ht/JOBS. Upstream derives those from a GraphQL enum; this service has no @access directive to derive from, so the spelling was free and drifted. It matters because meta.sr.ht compares the string verbatim. Its oauth2 blueprint fetches every service's api-meta.json once at import time and validates a requested grant with `scope in service_scopes[svc]` — no case folding, no aliasing. So a grant typed by hand as dolt.sr.ht/REPOS:RO was refused, by analogy with every neighbouring service. DATABASES rather than REPOS: that is what the surface calls the object everywhere a user meets it — the GraphQL databases connection, the web pages, the docs. The storage layer underneath still says "repo"; renaming that is a deeper change and is not what a token grant names. No backward compatibility. auth.Grants.Has is a map lookup, so a PAT minted against the old spelling is refused rather than quietly honoured, and two table cases assert that instead of leaving it implied. Deploying this is two-sided: meta.sr.ht has to be restarted before anyone can mint a token carrying the new name. The federation gateway is unaffected — it forwards the client's Authorization header to each service and never reads api-meta.json. sr-ht-dolt-xic --- README.md | 24 +++++++++++++++++------- authn/bearer.go | 8 ++++---- authn/bearer_test.go | 10 +++++----- authn/token.go | 32 +++++++++++++++++++++++++------- authn/token_test.go | 17 ++++++++++++----- cmd/doltsrht/graphql.go | 19 ++++++++++++------- cmd/doltsrht/graphql_test.go | 21 ++++++++++++++++----- cmd/doltsrht/main.go | 2 +- cmd/doltsrht/main_test.go | 2 +- config.example.ini | 6 ++++-- docs/DESIGN.md | 6 ++++-- graph/server.go | 2 +- mcpsrv/http_test.go | 2 +- remoteapi/integration_test.go | 6 +++--- remoteapi/interceptors.go | 2 +- remoteapi/interceptors_test.go | 6 +++--- 16 files changed, 110 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 7d145063a40d78914a2ab45ad3718e016d2ccfb1..436ac3922f3be0ff0e1d5eb68819ceb1e4095c5b 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ Once the service is up and you are logged into meta: ``` `--user` selects HTTP Basic auth; the password comes from - `DOLT_REMOTE_PASSWORD`. A PAT with the `dolt.sr.ht/repos:RW` grant (or an + `DOLT_REMOTE_PASSWORD`. A PAT with the `dolt.sr.ht/DATABASES:RW` grant (or an empty-grant personal token) can push; `:RO` or no grant can only read. 3. **Or clone/push with a dolt keypair (the git-SSH-key-like UX):** @@ -315,7 +315,7 @@ flow). Two tokens are accepted. A **tokens.sr.ht working token** must carry the a read — and it works only on an instance that configures `[tokens.sr.ht] origin`; without that section there is no daemon to verify it against, so it is refused with 401 while everything else keeps working. A **meta personal access -token** is accepted with the same `dolt.sr.ht/repos:RO` scoping the clone path +token** is accepted with the same `dolt.sr.ht/DATABASES:RO` scoping the clone path applies. No credential at all is a normal caller: it reads what an anonymous visitor reads, and a PRIVATE database it may not see is "not found" rather than "forbidden", exactly as in the web UI. @@ -342,7 +342,7 @@ There are no mutations; creating, renaming and deleting a database stay behind the web UI. The credential plane is `/mcp`'s exactly — a meta personal access token scoped -`dolt.sr.ht/repos:RO`, or a tokens.sr.ht working token carrying `dolt:read` — +`dolt.sr.ht/DATABASES:RO`, or a tokens.sr.ht working token carrying `dolt:read` — and no credential at all is a normal caller that reads what an anonymous visitor reads. A database the caller may not see resolves to `null` rather than to an authorization error, so its existence cannot be read out of the shape of the @@ -353,10 +353,20 @@ Listings page with the instance-standard opaque cursor: `databases(cursor: "…")` continues the walk. Beside it, `/query/api-meta.json` publishes the one grant this service defines -(`repos`), which is what lets meta.sr.ht offer `dolt.sr.ht/repos:RO` on its -personal-token page. To federate the schema into `api.sr.ht`, give the gateway's -config a `[dolt.sr.ht] api-origin=` line pointing here and SIGHUP it; nothing in -this service depends on the gateway existing. +(`DATABASES`), which is what lets meta.sr.ht offer `dolt.sr.ht/DATABASES:RO` on +its personal-token page. Upper case is not decoration: meta discovers the list +once at startup and then validates a requested grant against it verbatim, so the +name a user types has to match character for character — and every other service +on the instance names its scopes that way. **Deploying a change to it is +two-sided**: this service ships the new name, and meta.sr.ht has to be restarted +before anyone can mint a token carrying it. + +To federate the schema into `api.sr.ht`, give the gateway's config a +`[dolt.sr.ht] api-origin=` line pointing here and SIGHUP it; nothing in this +service depends on the gateway existing. The gateway does not read this file — +it forwards the client's `Authorization` header to each service and lets the +service decide — so the grant name concerns meta.sr.ht and hand-written grants, +not federation. **`hut graphql dolt` needs a patched hut.** Upstream v0.8.0 carries a hard-coded list of the ten sr.ht services and dereferences a nil entry for anything else, diff --git a/authn/bearer.go b/authn/bearer.go index b06b69905cf4f9054d8eb378fca23310bcc2fc2a..f3adcc7416f4ef31270c180c9d2e3d6dde39a3e0 100644 --- a/authn/bearer.go +++ b/authn/bearer.go @@ -29,7 +29,7 @@ const bearerScheme = "Bearer" // ErrMissingGrant is the sentinel wrapped by every "the credential is good, it // does not cover this" refusal: a working token without core.GrantRead, or a -// meta PAT whose OAuth grants do not reach dolt.sr.ht repositories. It is a 403 +// meta PAT whose OAuth grants do not reach this service's databases. It is a 403 // — retrying with the same token is pointless, and the holder needs to be told // to ask for a wider grant rather than to authenticate again. // @@ -231,7 +231,7 @@ func resolveWorkingToken(ctx context.Context, v InstanceValidator, presented str ac.AuthMethod = AuthMethodInstanceToken // BearerToken and Grants are deliberately left unset. They are core-go's // OAuth fields, and filling them would subject this caller to - // TokenGrantsAllow — a gate demanding "dolt.sr.ht/repos:RO", which a + // TokenGrantsAllow — a gate demanding "dolt.sr.ht/DATABASES:RO", which a // tokens.sr.ht grant string can never spell. A working token is scoped by // its own vocabulary, on BearerCaller.Grants, and by core.Allowed. @@ -260,8 +260,8 @@ func resolveMetaPAT(ctx context.Context, username, presented string) (*BearerCal // The whole surface is a read, so the gate can be applied once here rather // than per action. It is the same check the clone path applies. if !TokenGrantsAllow(ac, core.AccessRO) { - return nil, fmt.Errorf("%w: %w: token grants do not permit %s on %s repositories", - ErrInvalidToken, ErrMissingGrant, core.AccessRO, RepoScope) + return nil, fmt.Errorf("%w: %w: token grants do not permit %s under %s", + ErrInvalidToken, ErrMissingGrant, core.AccessRO, DatabaseScope) } return &BearerCaller{AuthContext: ac, InstanceToken: false}, nil diff --git a/authn/bearer_test.go b/authn/bearer_test.go index 518c4f4f061bffe22977b495cd33dd969014a695..9742f00c81d81a7959900b829e31b641143058fc 100644 --- a/authn/bearer_test.go +++ b/authn/bearer_test.go @@ -204,7 +204,7 @@ func TestResolveBearer_WorkingToken_NoValidatorIsRefused(t *testing.T) { }) t.Run("a meta PAT still resolves", func(t *testing.T) { - pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour)) + pat := forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(time.Hour)) bc, err := ResolveBearer(testCtx(), nil, pat) require.NoError(t, err) assert.False(t, bc.InstanceToken) @@ -240,7 +240,7 @@ func TestResolveBearer_MetaPAT_SufficientGrants(t *testing.T) { "bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER), }}) v := &fakeValidator{err: errBackendDown} // must not be consulted at all - pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour)) + pat := forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(time.Hour)) bc, err := ResolveBearer(testCtx(), v, pat) require.NoError(t, err) @@ -284,7 +284,7 @@ func TestResolveBearer_MetaPAT_InsufficientGrants(t *testing.T) { } func TestResolveBearer_MetaPAT_Revoked(t *testing.T) { - pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour)) + pat := forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(time.Hour)) withStubBackend(t, &stubBackend{ users: map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)}, revoked: map[[64]byte]bool{sha512.Sum512([]byte(pat)): true}, @@ -300,7 +300,7 @@ func TestResolveBearer_MetaPAT_BackendDownIsTransient(t *testing.T) { users: map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)}, revokeErr: errBackendDown, }) - pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour)) + pat := forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(time.Hour)) _, err := ResolveBearer(testCtx(), nil, pat) require.Error(t, err) @@ -317,7 +317,7 @@ func TestResolveBearer_UnusableCredentials(t *testing.T) { }{ {"empty (the caller lost its own header)", ""}, {"garbage", "this-is-not-a-token"}, - {"expired meta PAT", forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(-time.Minute))}, + {"expired meta PAT", forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(-time.Minute))}, {"expired working token", forgeWorkingToken("bigbes", time.Now().Add(-time.Minute))}, } for _, tc := range cases { diff --git a/authn/token.go b/authn/token.go index b1107ec2392c92aa725faa249311ae6bed2797dc..8ff599844150d2fc9d8e4012dd555a104d4a2cc1 100644 --- a/authn/token.go +++ b/authn/token.go @@ -12,11 +12,25 @@ import ( "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) -// RepoScope is the OAuth grant scope a meta.sr.ht personal access token must -// carry to act on dolt.sr.ht repositories: "dolt.sr.ht/repos". Reads require -// ":RO", pushes require ":RW". Personal tokens with no explicit grants are -// universal and pass unconditionally (auth.Grants.HasAll semantics). -const RepoScope = "dolt.sr.ht/repos" +// DatabaseScope is the OAuth grant scope a meta.sr.ht personal access token +// must carry to act on this service's databases: "dolt.sr.ht/DATABASES". Reads +// require ":RO", pushes require ":RW". Personal tokens with no explicit grants +// are universal and pass unconditionally (auth.Grants.HasAll semantics). +// +// The spelling is not free. meta.sr.ht discovers a service's scopes from its +// api-meta.json and then validates a requested grant with a plain `scope in +// scopes` — no case folding, no aliasing — so the string published there and +// the string checked here must match exactly, and a grant a user types by hand +// must match too. Every other service on the instance names its scopes in +// upper case (git.sr.ht/REPOSITORIES, todo.sr.ht/TRACKERS, paste.sr.ht/PASTES), +// because upstream derives them from a GraphQL enum; this service has no +// @access directive to derive from, so it follows the convention deliberately. +// +// DATABASES rather than REPOS because that is what the surface calls the +// object everywhere a user meets it — the GraphQL `databases` connection, the +// web pages, the docs. The storage layer underneath still says "repo"; that is +// a separate, deeper rename and is not what a token grant names. +const DatabaseScope = "dolt.sr.ht/DATABASES" // tokenCacheTTL bounds how long a positively-resolved Basic token is trusted // without re-checking revocation on meta.sr.ht. A single push issues many RPCs; @@ -123,7 +137,7 @@ func ResolveBasic(ctx context.Context, username, password string) (*auth.AuthCon // TokenGrantsAllow reports whether the caller's token grants permit access at // the given mode (core.AccessRO for browse/clone, core.AccessRW for push) on -// dolt.sr.ht repositories. It is the OAuth-grant gate that complements the ACL +// this service's databases. It is the OAuth-grant gate that complements the ACL // decision in core.Allowed: a token must carry BOTH sufficient grants and a // sufficient ACL/visibility to act. // @@ -131,6 +145,10 @@ func ResolveBasic(ctx context.Context, username, password string) (*auth.AuthCon // and are not scoped by them, so they pass this gate unconditionally; their // access is decided solely by core.Allowed. Personal tokens with empty grants // are universal and also pass. +// +// The match is exact, DatabaseScope and nothing else: auth.Grants.Has is a map +// lookup, so a token minted against an older spelling of this scope is refused +// here rather than quietly honoured. Re-minting it is the fix. func TokenGrantsAllow(ac *auth.AuthContext, mode core.AccessMode) bool { if ac == nil || ac.BearerToken == nil { return true @@ -139,5 +157,5 @@ func TokenGrantsAllow(ac *auth.AuthContext, mode core.AccessMode) bool { if mode == core.AccessRW { kind = auth.RW } - return ac.Grants.Has(RepoScope, kind) + return ac.Grants.Has(DatabaseScope, kind) } diff --git a/authn/token_test.go b/authn/token_test.go index d353d4fabe0f317c0eb0b282ed6a2bdb7c76a1be..66403bff69dbcf5b74498daf7213db2620a37b0a 100644 --- a/authn/token_test.go +++ b/authn/token_test.go @@ -219,11 +219,18 @@ func TestTokenGrantsAllow(t *testing.T) { {"cookie (no bearer) passes", &auth.AuthContext{AuthMethod: auth.AUTH_COOKIE}, core.AccessRW, true}, {"empty grants RO", patAC(""), core.AccessRO, true}, {"empty grants RW", patAC(""), core.AccessRW, true}, - {"repos:RO allows read", patAC("dolt.sr.ht/repos:RO"), core.AccessRO, true}, - {"repos:RO denies write", patAC("dolt.sr.ht/repos:RO"), core.AccessRW, false}, - {"repos:RW allows read", patAC("dolt.sr.ht/repos:RW"), core.AccessRO, true}, - {"repos:RW allows write", patAC("dolt.sr.ht/repos:RW"), core.AccessRW, true}, - {"unrelated scope denies read", patAC("git.sr.ht/repos:RW"), core.AccessRO, false}, + {"DATABASES:RO allows read", patAC("dolt.sr.ht/DATABASES:RO"), core.AccessRO, true}, + {"DATABASES:RO denies write", patAC("dolt.sr.ht/DATABASES:RO"), core.AccessRW, false}, + {"DATABASES:RW allows read", patAC("dolt.sr.ht/DATABASES:RW"), core.AccessRO, true}, + {"DATABASES:RW allows write", patAC("dolt.sr.ht/DATABASES:RW"), core.AccessRW, true}, + {"unrelated scope denies read", patAC("git.sr.ht/REPOSITORIES:RW"), core.AccessRO, false}, + // The scope was published lower case as "repos" before the instance + // convention was followed. auth.Grants.Has is a map lookup, so a token + // minted then is refused rather than silently honoured — the two cases + // below are what "no backward compatibility" means, asserted rather + // than assumed. + {"legacy lower-case scope denies read", patAC("dolt.sr.ht/repos:RW"), core.AccessRO, false}, + {"legacy lower-case scope denies write", patAC("dolt.sr.ht/repos:RW"), core.AccessRW, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/cmd/doltsrht/graphql.go b/cmd/doltsrht/graphql.go index c748482f3e251497677e06e05011a9b4c243d846..e3d2d5a0810f1403eeb8955bf4ebd9c055b7b5b9 100644 --- a/cmd/doltsrht/graphql.go +++ b/cmd/doltsrht/graphql.go @@ -16,13 +16,18 @@ import ( // ecore's apimeta, at apimeta.Path. const queryRoute = "/query" -// repoScopeName is the one grant this service defines, spelled as meta.sr.ht -// expects it: the part after the service name in authn.RepoScope -// ("dolt.sr.ht/repos"). meta prefixes the service name itself. The two -// spellings are the same fact written twice, so a test asserts them equal — -// a drift would let a user mint a token meta calls valid and this service does -// not honour. -const repoScopeName = "repos" +// databaseScopeName is the one grant this service defines, spelled as +// meta.sr.ht expects it: the part after the service name in +// authn.DatabaseScope ("dolt.sr.ht/DATABASES"). meta prefixes the service name +// itself. The two spellings are the same fact written twice, so a test asserts +// them equal — a drift would let a user mint a token meta calls valid and this +// service does not honour. +// +// meta reads this list once, at import time (metasrht/blueprints/oauth2.py), +// and validates a requested grant against it verbatim. So changing it is a +// two-sided deployment: this service ships the new name, and meta.sr.ht has to +// be restarted before anyone can mint a token carrying it. +const databaseScopeName = "DATABASES" // graphBrowseOpener satisfies graph.BrowseOpener over browse.Open, as // mcpBrowseOpener does for the MCP surface and web.BrowseAdapter for the pages: diff --git a/cmd/doltsrht/graphql_test.go b/cmd/doltsrht/graphql_test.go index d06a69fb0666841fcde8d37a36b0e91bf22679af..a3dbd189a6833993146f2b527355ed009af96047 100644 --- a/cmd/doltsrht/graphql_test.go +++ b/cmd/doltsrht/graphql_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -16,24 +17,34 @@ import ( // The scope this service advertises and the scope it enforces are the same fact // written twice: meta.sr.ht prefixes the service name to what it reads from -// api-meta.json, and authn.RepoScope is what a presented token is checked +// api-meta.json, and authn.DatabaseScope is what a presented token is checked // against. A drift would let a user mint a token meta calls valid and the clone // path does not honour, which is a support ticket rather than an error. func TestTheAdvertisedScopeIsTheEnforcedOne(t *testing.T) { - assert.Equal(t, authn.RepoScope, serviceName+"/"+repoScopeName) + assert.Equal(t, authn.DatabaseScope, serviceName+"/"+databaseScopeName) +} + +// The name is upper case because meta.sr.ht compares it verbatim — no case +// folding — and every other service on the instance names its scopes that way +// (git.sr.ht/REPOSITORIES, todo.sr.ht/TRACKERS, paste.sr.ht/PASTES). A lower +// case scope is accepted by meta and works, which is exactly why the drift +// survived: it breaks only the user who spells the grant by hand, by analogy +// with every neighbouring service. +func TestTheScopeIsSpelledLikeEveryOtherServices(t *testing.T) { + assert.Equal(t, strings.ToUpper(databaseScopeName), databaseScopeName) } // The wiring, not the package: ecore's apimeta owns the never-null rule, and // this asserts dolt.sr.ht actually declares the grant it enforces rather than // serving an empty list that would leave meta with no checkbox to offer. -func TestAPIMetaAdvertisesTheRepoScope(t *testing.T) { +func TestAPIMetaAdvertisesTheDatabaseScope(t *testing.T) { rec := httptest.NewRecorder() - apimeta.Handler(repoScopeName).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, apimeta.Path, nil)) + apimeta.Handler(databaseScopeName).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, apimeta.Path, nil)) require.Equal(t, http.StatusOK, rec.Code) var got apimeta.Meta require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - assert.Equal(t, []string{"repos"}, got.Scopes) + assert.Equal(t, []string{"DATABASES"}, got.Scopes) assert.NotContains(t, rec.Body.String(), "null") } diff --git a/cmd/doltsrht/main.go b/cmd/doltsrht/main.go index 1c4cfb6384d8ddef642de19e4464ffec16d4bc24..03bff290e98b5064cb86618a0bad27f7b1755651 100644 --- a/cmd/doltsrht/main.go +++ b/cmd/doltsrht/main.go @@ -355,7 +355,7 @@ func mountRoutes(r chi.Router, s surfaces) error { // The file meta.sr.ht reads to learn what this service can be granted. // A service that mounts its own /query owes the instance this too — // core-go serves it only for the schemas it hosts itself. - r.Get(apimeta.Path, apimeta.Handler(repoScopeName)) + r.Get(apimeta.Path, apimeta.Handler(databaseScopeName)) }) r.Use(authn.OptionalCookieMiddleware()) // never 401s; anonymous stays anonymous diff --git a/cmd/doltsrht/main_test.go b/cmd/doltsrht/main_test.go index d029f66137aeeb39e6918f835774672ed7c80b5c..0c44f3905661d764f9d93df4618c0edd2a2e694a 100644 --- a/cmd/doltsrht/main_test.go +++ b/cmd/doltsrht/main_test.go @@ -505,6 +505,6 @@ func TestTheAPIMetaFileIsServed(t *testing.T) { var got apimeta.Meta require.NoError(t, json.Unmarshal(raw, &got)) - assert.Equal(t, []string{repoScopeName}, got.Scopes) + assert.Equal(t, []string{databaseScopeName}, got.Scopes) assert.NotEmpty(t, got.WebhookPubkey) } diff --git a/config.example.ini b/config.example.ini index d6af128ddb6abc5d9078c3840422de8ab83b645e..06432da906fcb264a4aa722229a6ec958c45e4e3 100644 --- a/config.example.ini +++ b/config.example.ini @@ -61,8 +61,10 @@ log-level=info ; the credential presented — the bearer plane /mcp defines, or none at all for ; an anonymous caller reading public databases — and by the visibility rules the ; web UI applies. Beside it, /query/api-meta.json tells meta.sr.ht that this -; service defines the "repos" grant, which is what makes -; "dolt.sr.ht/repos:RO" offerable on the personal-token page. +; service defines the "DATABASES" grant, which is what makes +; "dolt.sr.ht/DATABASES:RO" offerable on the personal-token page. meta reads +; that file once at startup, so it has to be restarted after this service +; changes the name — until then it offers the old one and refuses the new. ; ; There is deliberately no mcp-enabled key. The read-only MCP surface an agent ; calls (docs/DESIGN.mcp.md) is served at /mcp on the web listener above, diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a3f2b3f357a9cce1aa85e1e854bdd3abd3790f12..94c8b6b2a46d4087b9e6d4b1c900ed6e43692032 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -215,8 +215,10 @@ Ops: OpBrowse, OpCloneRead, OpPush, OpAdmin. | owner | all | all | all | PRIVATE + unauthorized ⇒ NotFound (don't leak existence). Suspended users: reads -allowed, push/admin denied. Token grants: `dolt.sr.ht/repos:RO` for reads, `:RW` for -push (empty-grant personal tokens pass via `HasAll`). +allowed, push/admin denied. Token grants: `dolt.sr.ht/DATABASES:RO` for reads, `:RW` for +push (empty-grant personal tokens pass via `HasAll`). The grant name is matched +exactly — `auth.Grants.Has` is a map lookup — so the spelling published in +`/query/api-meta.json` and the one enforced here are asserted equal by a test. ## remotesapi subsystem diff --git a/graph/server.go b/graph/server.go index d7a0b7777bb828ab0c2346125acf86204394ed43..1a14afc5e8c2f11e297fa9def0e0343c8fea846c 100644 --- a/graph/server.go +++ b/graph/server.go @@ -24,7 +24,7 @@ // every field applies the same access matrix the web pages and the MCP tools do: // // - The credential is the bearer plane /mcp already defines — a meta personal -// access token scoped `dolt.sr.ht/repos:RO`, or a tokens.sr.ht working token +// access token scoped `dolt.sr.ht/DATABASES:RO`, or a tokens.sr.ht working token // carrying `dolt:read`. No cookie: an API client is not a browser, and this // endpoint is deliberately outside web's same-origin group. // - Anonymous is a normal caller. It reads what an anonymous visitor reads, diff --git a/mcpsrv/http_test.go b/mcpsrv/http_test.go index 08a1d7bcf91bfdef12f18c6d0dcc2544d07edd96..90eb175a187ff8313fd1e6eb5f46d769efad21b2 100644 --- a/mcpsrv/http_test.go +++ b/mcpsrv/http_test.go @@ -155,7 +155,7 @@ func mountMCP(t *testing.T, tokensDaemon bool) (*httptest.Server, credentials) { aliceWorking: forgeWorkingToken("alice"), bobWorking: forgeWorkingToken("bob"), withoutRead: forgeWorkingToken("alice"), - alicePAT: forgePAT("alice", "dolt.sr.ht/repos:RO"), + alicePAT: forgePAT("alice", "dolt.sr.ht/DATABASES:RO"), foreignPAT: forgePAT("alice", "git.sr.ht/repos:RW"), unverifiable: forgeWorkingToken("alice"), garbage: "not-a-token", diff --git a/remoteapi/integration_test.go b/remoteapi/integration_test.go index 1d270d22f607138dcd685536c3915588de365d04..697ffeb96df3b93bd047a29820adb70a03542388 100644 --- a/remoteapi/integration_test.go +++ b/remoteapi/integration_test.go @@ -200,7 +200,7 @@ func TestRemoteAPIIntegration(t *testing.T) { privURL := fmt.Sprintf("http://%s/~alice/privdb", addr) roURL := fmt.Sprintf("http://%s/~alice/rodb", addr) - pat := forgePAT("alice", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour)) + pat := forgePAT("alice", "dolt.sr.ht/DATABASES:RW", time.Now().Add(time.Hour)) // (1) Anonymous clone of PUBLIC succeeds. t.Run("anonymous_public_clone", func(t *testing.T) { @@ -324,7 +324,7 @@ func TestRemoteAPIIntegration(t *testing.T) { // (7) ACL RO grantee (bob) can clone the private rodb but cannot push. t.Run("acl_ro_clone_not_push", func(t *testing.T) { - bobPAT := forgePAT("bob", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour)) + bobPAT := forgePAT("bob", "dolt.sr.ht/DATABASES:RW", time.Now().Add(time.Hour)) env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+bobPAT) dir := t.TempDir() @@ -387,7 +387,7 @@ func TestRemoteAPIIntegration(t *testing.T) { // owner-only guard, which also avoids leaking a stranger's namespace. t.Run("push_to_create_foreign_namespace_denied", func(t *testing.T) { foreignURL := fmt.Sprintf("http://%s/~alice/bobtried", addr) - bobPAT := forgePAT("bob", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour)) + bobPAT := forgePAT("bob", "dolt.sr.ht/DATABASES:RW", time.Now().Add(time.Hour)) env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+bobPAT) work := filepath.Join(t.TempDir(), "bobtried") diff --git a/remoteapi/interceptors.go b/remoteapi/interceptors.go index 0d29122dbe234f5b6dd765db8fa7682f6de60033..390341a2e96e9eaafaf41d645cecb552c7fa8f88 100644 --- a/remoteapi/interceptors.go +++ b/remoteapi/interceptors.go @@ -205,7 +205,7 @@ func (i *interceptor) authenticate(ctx context.Context) (*auth.AuthContext, erro // // 1. classify the method (unknown ⇒ PermissionDenied); // 2. OAuth grant gate (TokenGrantsAllow) — PAT callers must carry -// dolt.sr.ht/repos:RO for reads / :RW for pushes; anonymous, cookie and +// dolt.sr.ht/DATABASES:RO for reads / :RW for pushes; anonymous, cookie and // dolt-key callers pass trivially; // 3. extract the repo path (Root with no path ⇒ unauthenticated-OK ping); // 4. load the repository row and the caller's effective ACL; diff --git a/remoteapi/interceptors_test.go b/remoteapi/interceptors_test.go index bab0953b876df7bf6817af8f2b0aee964b8cdbd3..a1aac5c633e9f0686056e2286defcb9ab94a4261 100644 --- a/remoteapi/interceptors_test.go +++ b/remoteapi/interceptors_test.go @@ -299,20 +299,20 @@ func TestAuthorizeGrantGate(t *testing.T) { t.Run("PAT with RO grant allowed on read of own repo", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)}) - if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mGetMeta, req); err != nil { + if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/DATABASES:RO"), mGetMeta, req); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("PAT with RO grant cannot push", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)}) - _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mCommit, req) + _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/DATABASES:RO"), mCommit, req) wantCode(t, err, codes.PermissionDenied) }) t.Run("PAT with RW grant pushes own repo", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)}) - if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RW"), mCommit, req); err != nil { + if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/DATABASES:RW"), mCommit, req); err != nil { t.Fatalf("unexpected error: %v", err) } })