| 1 |
|
package graph |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"log/slog" |
| 8 |
|
|
| 9 |
|
"go.bigb.es/auxilia/scribe" |
| 10 |
|
|
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/authn" |
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/core" |
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/db" |
| 14 |
|
) |
| 15 |
|
|
| 16 |
|
// Resolver holds what every field of the schema resolves through. It is built |
| 17 |
|
// once by New and is safe for concurrent use: it owns no per-request state, and |
| 18 |
|
// the caller is read from the context the transport installed. |
| 19 |
|
type Resolver struct { |
| 20 |
|
repos Repos |
| 21 |
|
opener BrowseOpener |
| 22 |
|
} |
| 23 |
|
|
| 24 |
|
// defaultLogLimit is how many commits Database.log answers when the query names |
| 25 |
|
// no limit, and maxLogLimit is the most it will answer for any limit. The cap is |
| 26 |
|
// the surface's and not the client's: a log page is a walk of the on-disk store, |
| 27 |
|
// and an unbounded limit is a way to ask this service to walk all of history in |
| 28 |
|
// one request. |
| 29 |
|
const ( |
| 30 |
|
defaultLogLimit = 20 |
| 31 |
|
maxLogLimit = 200 |
| 32 |
|
) |
| 33 |
|
|
| 34 |
|
// errUnavailable is what a resolver answers when it could not read, as opposed |
| 35 |
|
// to when there was nothing to read. The two are never merged: "I could not |
| 36 |
|
// check" reported as "there is none" is how a client learns a false fact about |
| 37 |
|
// the instance and acts on it. |
| 38 |
|
var errUnavailable = errors.New("the database could not be read, try again") |
| 39 |
|
|
| 40 |
|
// internalError logs the cause against the field that produced it and returns |
| 41 |
|
// the sentence a client sees. The cause never travels: the store layer's errors |
| 42 |
|
// carry on-disk paths and the metadata layer's carry connection strings. |
| 43 |
3 |
func internalError(field string, err error) error { |
| 44 |
3 |
slog.Error("a GraphQL field could not be resolved", |
| 45 |
3 |
"component", "graph", "field", field, scribe.Err(err)) |
| 46 |
3 |
return errUnavailable |
| 47 |
3 |
} |
| 48 |
|
|
| 49 |
|
// callerOf is the request's principal as the access matrix wants it: nil for an |
| 50 |
|
// anonymous caller, which is a normal caller on this schema. |
| 51 |
30 |
func callerOf(ctx context.Context) *core.Caller { |
| 52 |
30 |
return authn.AsCoreCaller(authn.CallerFromContext(ctx)) |
| 53 |
30 |
} |
| 54 |
|
|
| 55 |
|
// resolveDatabase turns an owner and a name into a row the caller may read, or |
| 56 |
|
// into (nil, nil) — "no such database" — which is the schema's single refusal. |
| 57 |
|
// |
| 58 |
|
// It is the browse handlers' dance call for call (web's loadRepoForBrowse, |
| 59 |
|
// mcpsrv's resolveDatabase): the row, the caller's ACL grant, core.Allowed for |
| 60 |
|
// OpBrowse. Like the MCP surface and unlike the web, there is no forbidden arm: |
| 61 |
|
// a caller who may not read a database is told it does not exist, so its |
| 62 |
|
// existence cannot be read out of the shape of the refusal. |
| 63 |
|
// |
| 64 |
|
// An ACL lookup that fails is an error rather than a fall-through to |
| 65 |
|
// visibility. On a page that degradation costs a signed-in user a rendering; |
| 66 |
|
// here it would tell a client that a database it holds a grant on is not there. |
| 67 |
12 |
func (r *Resolver) resolveDatabase(ctx context.Context, owner, name string) (*core.Repo, error) { |
| 68 |
12 |
caller := callerOf(ctx) |
| 69 |
12 |
|
| 70 |
12 |
repo, err := r.repos.GetRepoByOwnerAndName(ctx, owner, name) |
| 71 |
12 |
if err != nil { |
| 72 |
1 |
if errors.Is(err, db.ErrNotFound) { |
| 73 |
1 |
return nil, nil |
| 74 |
1 |
} |
| 75 |
0 |
return nil, internalError("database", err) |
| 76 |
|
} |
| 77 |
|
|
| 78 |
|
// An anonymous caller holds no ACL entry and there is no user id to look one |
| 79 |
|
// up by; visibility decides alone, which is what core.Allowed does with a |
| 80 |
|
// nil grant. |
| 81 |
11 |
var mode *core.AccessMode |
| 82 |
11 |
if caller != nil { |
| 83 |
6 |
if mode, err = r.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil { |
| 84 |
1 |
return nil, internalError("database", err) |
| 85 |
1 |
} |
| 86 |
|
} |
| 87 |
10 |
if !core.Allowed(caller, repo, mode, core.OpBrowse) { |
| 88 |
2 |
return nil, nil |
| 89 |
2 |
} |
| 90 |
8 |
return repo, nil |
| 91 |
|
} |
| 92 |
|
|
| 93 |
|
// openStore opens the bare store of a database whose row the caller has already |
| 94 |
|
// been allowed to read. The caller closes the session. |
| 95 |
9 |
func (r *Resolver) openStore(ctx context.Context, field string, repo *core.Repo) (BrowseSession, error) { |
| 96 |
9 |
sess, err := r.opener.Open(ctx, repo.Path) |
| 97 |
9 |
if err != nil { |
| 98 |
1 |
return nil, internalError(fmt.Sprintf("Database.%s", field), err) |
| 99 |
1 |
} |
| 100 |
8 |
return sess, nil |
| 101 |
|
} |