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.

ts
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 one ref reads null. 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 redact value. 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.validate refuses one, as it refuses a RemoteServer.entity without GuardServer.memory's or GuardDrizzle.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:

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 Option of the set's schema is a defect, never read as a visitor.
  • visible is the row's Read. Member and relation Reads are the Source's filter, as TodoSource has 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:

ts
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.