Guard / foldkit-guard
Serving reads
foldkit-guard/server turns the rules into foldkit-remote-server's seams.
It is a subpath, so the shared declaration the browser imports never brings
the server along.
import { GuardServer } from 'foldkit-guard/server'
import { Remote } from 'foldkit-remote'
import { RemoteServer } from 'foldkit-remote-server'
const visitor: Option.Option<typeof User.Type> = Option.none()
const Data = Remote.define({ entities: [Todo, Project] })
const backend = RemoteServer.memory({
domain: Data,
rows: {
Todo: [{ id: 't1', ownerId: 'ada', projectId: 'p1', title: 'Write', project: 'Project:p1' }],
Project: [{ id: 'p1', name: 'Borealis' }],
},
principal: visitor, // reads no todo: every Read rule here needs a principal
...GuardServer.memory(Policy, [Todo]),
})- A row its Read rules refuse is absent: from a read by id (the client reads
it as not found), from every query, and from relations, where a
oneref readsnull. An inherited Read looks the related row up and decides it by its own rules; a loop of them reads as refused. - A member its own Read rule refuses is withheld (settled, so the client
reads it as unavailable) or shows its
redactvalue. The fields the rule reads are read too, so it can be decided. - A relation's Read rule is decided over its parent row, as a member's is: where it refuses, the relation is withheld. The rows it leads to are still narrowed by their own Read rules.
- An Entity not listed has no guards to apply and is read by everyone.
- A Source of your own cannot be shown to apply the row's Read rules:
GuardServer.validaterefuses one, as it refuses aRemoteServer.entitywithoutGuardServer.memory's orGuardDrizzle.visible's rule. Serve a guarded Entity through one of those.
Over Drizzle, foldkit-guard/drizzle compiles the row's Read rules into each
binding's visible, which the binding applies on every path its table is
read by (by id, as a relation's target and children, counted, through a
query). Here todos and projects are your Drizzle tables for Todo and
Project; a fixture with a guarded Project too, run over SQLite, is
test/drizzle.test.ts:
import { GuardDrizzle } from 'foldkit-guard/drizzle'
import { bind, source } from 'foldkit-remote-drizzle'
const visible = GuardDrizzle.visible(Policy, [Todo])
const Db = bind(
{ Todo, Project },
{
Todo: {
table: todos,
relations: { project: { field: todos.projectId } },
derived: { may: { supplied: true } }, // not a column: `GuardServer.filter` answers it
visible: visible.Todo,
},
Project: { table: projects },
},
)
const TodoSource = source(Db.Todo, { filter: GuardServer.filter(Todo, Policy) })- Each principal guard is decided first; what is left is SQL. An inherited
Read is
project_id in (select id from projects where …), the related table's own rule inside. - It is checked when built, before any request: an inherited Read whose Entity is not listed or has no Read rule, or a loop of them, throws. An inherited relation must be stored as a foreign key on this table; one that is not fails its first read, which is when the binding is first known.
- The principal reaches a binding untyped, so it is checked: one that is not
an
Optionof the set's schema is a defect, never read as a visitor. visibleis the row's Read. Member and relation Reads are the Source'sfilter, asTodoSourcehas it.
A body may need a service, a membership lookup say, and name it in its
Effect. The read seams (GuardDrizzle.visible, GuardServer.memory,
GuardSync) take a Policy that needs nothing more, since a read rule runs
inside a read whose requirements are the Source's. Provide the services once,
at startup; what changes with each request belongs in the principal:
class Memberships extends Context.Service<
Memberships,
{ readonly admins: ReadonlyArray<string> }
>()('app/Memberships') {}
const Checked = G.implement({
isAdmin: principal =>
Effect.map(Memberships, ({ admins }) =>
Option.exists(principal, user => admins.includes(user.id)),
),
})
const ReadPolicy = Checked.provide(Context.make(Memberships, { admins: ['ada'] }))
GuardDrizzle.visible(ReadPolicy, [Todo]) // a type error with `Checked`A seam built from ReadPolicy counts as built from Checked, so
GuardServer.validate(Checked, …) accepts it: validate checks that a seam
runs the declared rules, not which services they were given. The write seams take the
Policy as it is and require its services where the write runs.