Documentation
¶
Overview ¶
Package studiobootstrap answers "what may this caller see and do" from the access rules the schema declared, so Studio does not have to guess.
Studio used to infer affordances from role names in the browser. That is a second implementation of the access rules living in a place where it cannot be enforced, and the two drift: the UI offers an action the database then refuses, or hides one it would have allowed. The rules are compiled to policies for enforcement and evaluated here for the interface, from the same AST.
Only *row-independent* rules can be answered without a row. Everything else is reported as row-dependent rather than guessed — an honest "it depends" is what lets Studio ask per row, and a guess is what makes a UI lie.
Index ¶
- Variables
- func FieldVerdictsForCaller(snapshot *Snapshot, caller Caller) (map[string]map[string]FieldVerdict, error)
- func IdentityScopedTables(ctx context.Context) (map[string]bool, bool)
- func IsIdentityDependent(raw json.RawMessage) bool
- func IsRowDependent(raw json.RawMessage) bool
- func MaskedFields(ctx context.Context) (map[string][]FieldMask, bool)
- func ResetIdentityScopeCache()
- type Caller
- type FieldMask
- type FieldVerdict
- type Model
- type Snapshot
- type Verdict
Constants ¶
This section is empty.
Variables ¶
var ErrNoSchemaState = errors.New("no schema state — push the schema first")
ErrNoSchemaState means the project has never been pushed, so there is nothing for Studio to render yet.
Functions ¶
func FieldVerdictsForCaller ¶
func FieldVerdictsForCaller( snapshot *Snapshot, caller Caller, ) (map[string]map[string]FieldVerdict, error)
FieldVerdictsForCaller resolves every field rule against this caller, per table.
Studio's use for it is not decoration: a masked cell should show a lock rather than an empty string, a readable-but-not-writable input should be disabled rather than silently rejected on save, and a required column nobody can create should be absent from a create form rather than block it. All three need the answer before any row is fetched.
Advisory, like everything else Studio is told. The enforcement is the security labels and the planner rewrite; a tampered response can only produce an interface that asks for something the database then refuses.
func IdentityScopedTables ¶
IdentityScopedTables returns the tables whose read rule varies by caller.
The second result is false when the classification could not be determined — the caller must then treat every table as identity-scoped, because "we could not check" is not a reason to start sharing responses between users.
func IsIdentityDependent ¶
func IsIdentityDependent(raw json.RawMessage) bool
IsIdentityDependent reports whether a rule's outcome varies by *who* is asking.
A shared cache entry for such a table serves one caller's rows to everyone, so this is what decides whether a response may be cached under a scope that is not keyed by identity.
Row-dependence is not the same question. `Lte<"published_at", Now>` varies by row and by time but not by caller, so a shared entry is fine for it — the whole point of allowing public caching on a published-content table.
func IsRowDependent ¶
func IsRowDependent(raw json.RawMessage) bool
IsRowDependent reports whether a rule's outcome varies by *which row* it is tested against.
A different question from IsIdentityDependent, and the two are easy to conflate because the commonest rule is both. `Role<"admin">` varies by caller and not by row; `Lte<"published_at", Now>` varies by row and not by caller; `Owner<"author_id">` varies by both.
It matters here because a masked-field header is computed before the query runs. For a rule that does not read the row, "masked" is a property of the whole result set. For one that does, the header can only say "may be masked in some rows" — and a client that treated that as exact would hide values the caller was entitled to see.
func MaskedFields ¶
MaskedFields returns, per table, the columns carrying a read restriction.
Derived from the AST, so it costs no database work per request and no second implementation of the rules — the *enforcement* of these restrictions is the security labels and the planner rewrite, which this only describes.
Caller-independent by design. It says which columns are restricted and whether the restriction varies by row, not whether *this* caller is masked: answering that would mean evaluating the predicate, and Studio already gets the exact per-caller answer from `/studio/session`. An app client wants to know which nulls are explicable, which this gives for free.
The second result is false when the classification could not be read. Callers then send no header at all — an absent header means "not stated", whereas a wrong one would be taken as fact.
func ResetIdentityScopeCache ¶
func ResetIdentityScopeCache()
ResetIdentityScopeCache clears the memoised classification. For tests.
Types ¶
type Caller ¶
type Caller struct {
// UserID is the token's subject; empty for an unauthenticated caller.
UserID string
// AppRole is the *developer's* application role, which is what `auth.role()`
// returns and what a `Role<>` rule tests. Not the Studio role: those are
// separate namespaces and conflating them here would report access the
// database will not grant.
AppRole string
// Claims is the token's claim set, for `Claim<>` operands.
Claims map[string]any
}
Caller is the identity an evaluation is relative to.
type FieldMask ¶
type FieldMask struct {
Column string `json:"column"`
// RowDependent means the verdict varies row by row, so the header can only warn.
RowDependent bool `json:"rowDependent"`
}
FieldMask is one column's read restriction, as far as it can be known before the query runs.
type FieldVerdict ¶
type FieldVerdict struct {
Read Verdict `json:"read"`
// Write is the UPDATE path.
Write Verdict `json:"write"`
// Create is the INSERT path, which is stricter: there is no row yet, so the masking
// extension evaluates the write predicate against `NULL::t` and a rule that reads the
// row yields NULL for every caller.
Create Verdict `json:"create"`
}
FieldVerdict is what this caller may do with one column, resolved as far as it can be without a row.
Three answers rather than one because the three paths genuinely differ. The clearest case is a required column with an ownership write rule: readable, updatable by its owner, and creatable by nobody — so a create form that renders it as a required input is asking for something no caller can supply.
type Model ¶
type Model struct {
Name string `json:"name"`
Table string `json:"table"`
// Access is what the caller may do, resolved as far as it can be without a
// row. `row` means Studio must ask per row rather than assume.
Access map[string]Verdict `json:"access"`
}
Model is one table as Studio needs to know about it.
func FilterForCaller ¶
FilterForCaller returns the models this caller can do anything with at all.
Filtered on the server, deliberately: sending the whole schema and hiding parts in the browser tells every caller what tables exist, which is information the access rules said they should not have. A model where every operation is denied is omitted entirely.
type Snapshot ¶
type Snapshot struct {
AST json.RawMessage
AdminConfig json.RawMessage
// Hash covers the AST and the admin config, so a client's cached copy is
// invalidated by any change to either. Keyed on content rather than a
// timestamp: a push that changes nothing should not invalidate anything.
Hash string
}
Snapshot is the schema as the last push recorded it, plus a hash to cache on.
type Verdict ¶
type Verdict string
Verdict is what can be said about an operation before a row is in hand.
const ( // VerdictAllow — every caller in this session passes, whatever the row. VerdictAllow Verdict = "allow" // VerdictDeny — no row can satisfy it, so Studio should not offer the action. VerdictDeny Verdict = "deny" // VerdictRow — depends on the row. Studio must ask per row (or let the // database refuse) rather than assume either way. VerdictRow Verdict = "row" )