Lacunari

Writing

Team mode: identity, permissions, and what changes when you turn it on

LAC_NAME=someone-else is one environment variable away from posting as a colleague, in the default install. That is not a bug — it is what "one shared credential" means, honestly stated. Here is what actually changes when you stop trusting the environment variable.

Start with the honest description of the default, because team mode only makes sense in contrast to it. Out of the box, Lacunari trusts everyone who holds the connection string and takes your word for who you are. Identity is the LAC_NAME environment variable. Nothing checks it against anything. That is the right amount of ceremony for one person running several agents against their own database — and the wrong amount the moment a second person is added, because at that point LAC_NAME is not identity, it's an unenforced claim.

What "team mode off" really means

Self-asserted identity is not a lesser version of real identity — it's a different thing wearing the same field name. Any process with the shared DSN can set LAC_NAME to anything, post to the bus as anyone, and claim a task under anyone's name. `cperm` and `crule`, the cooperative permission gate and policy engine, are real and useful in this mode — they stop an agent that checks them from doing something it shouldn't. They do nothing at all to an agent, or a person, that simply doesn't ask. That is the precise sense in which the default is "not a security boundary until you turn one on": the gate is advisory, and advisory gates only constrain parties that voluntarily walk through the gate.

What team mode enforces

Team mode's premise is one sentence: move identity and permissions out of anything the client controls and into the one thing neither the client nor a compromised agent can rewrite — the database's own connection.

QuestionDefaultTeam mode
IdentityLAC_NAME, self-assertedthe connected Postgres role, server-stamped
Impersonationtrivial — set the env varimpossible — the server overwrites the sender
Lane permissionscperm, advisoryrow-level security, enforced
Widening your own permissionsjust edit the rowblocked by policy
Removing someonerotate the one shared password for everyonelac-admin revoke <name>, one member
Auditnoneappend-only, no member can rewrite it

Three mechanisms carry that whole table. Identity is the Postgres role the client authenticated as — not a column any query can set, but the thing the TLS-terminated connection itself proves. Every person and every agent gets their own login role and their own DSN, and LAC_NAME=someone-else after that point does nothing, because the server stamps the sender from the role the connection came in on, not from anything the client said about itself. Permissions are row-level security policies — Postgres evaluating, on every read and write, whether the connected role is allowed to touch that row, using pg_has_role() against the role's actual database membership rather than a permissions column the role itself might be able to edit. The audit log is an append-only table: inserts are permitted, updates and deletes are not, for anyone, which is the part that makes "who did this" survive even a member who would like it not to.

All three follow from one design choice stated flatly in this project's own status notes: Postgres does the enforcing. The CLI is a convenience, not the boundary. `cperm` still runs in team mode and still returns the same exit codes an automation might depend on — it just stops being the thing standing between a member and a lane they shouldn't touch, and becomes a cheap way to ask about a boundary the database was going to enforce regardless of whether you asked first.

The bug that shows why "enforced" is harder than it sounds

It would be easy to describe row-level security as a checkbox — turn it on, done. The project's own history says otherwise, and the specific failure is worth walking through because it is exactly the kind of mistake a reasonable engineer makes on the first attempt.

The keeper — the process that reclaims stale claims and releases dead agents' locks — has to act across every lane and on other members' rows. The first implementation gave it that reach by granting it membership in a role with the BYPASSRLS attribute, which sounds exactly right: bypass row-level security, granted to the process that legitimately needs to bypass row-level security.

It didn't work, and it failed in the worst possible way: silently. The keeper connected without error. It ran its queries without error. And it released nothing, ever — every stale claim it should have reclaimed sat there untouched, forever, with no exception raised anywhere. The keeper reported success on every run.

The cause is a specific, non-obvious fact about Postgres role membership: role attributes are not inherited through membership. Only privileges are. BYPASSRLS is an attribute, not a privilege. Being a member of a role that has BYPASSRLS does not give you BYPASSRLS — you would need the attribute granted directly to your own role, which defeats the entire point of using group membership to manage a fleet of operator identities in the first place. The keeper had exactly the access of an ordinary member with no rows to its name, doing everything correctly and accomplishing nothing.

The fix was to stop routing operator access through a role attribute at all. Team mode's policies now test with pg_has_role(), which checks actual role membership rather than an attribute that quietly doesn't propagate — so --admin grants membership in lac_admin, and every policy that needs to recognize an operator asks the membership question directly instead of hoping an attribute rode along. It is a small change with a specific consequence for anyone running this themselves: an operator identity built on BYPASSRLS will silently do nothing, and the only way to have caught this from the outside was to actually observe that a stale claim never got released — which is precisely why the project's own testing discipline is now to assert on the artifact (did the claim actually clear) rather than the exit code (did the keeper run without error).

The other two identity bugs in the same family

Two more findings from the same hardening pass are worth knowing before you run this for real, because both are failures where the system reported success while doing something other than what it claimed.

The audit trigger was originally SECURITY DEFINER — a function that runs with the privileges, and inside which current_user resolves to, the function's owner, not whoever actually called it. Every action in the supposedly immutable audit log was attributed to the same account regardless of who did it. The log was inserting rows correctly and telling you nothing true about who caused them. It now runs as invoker, with a trigger that stamps actor from the real caller, so the INSERT grant itself cannot be used to forge a name.

Separately, revoke originally left live sessions running: the query meant to terminate a revoked member's active connections was malformed and failed silently, so disabling someone's login stopped new connections but did nothing to the session they already had open — they kept working until they happened to disconnect on their own. Revocation now terminates the live session as part of the same operation, which is the difference between "access removed" and "access removed the next time it happens to matter."

What team mode still does not do

The honest boundary matters as much as the mechanism, and it is worth stating without softening it:

  • Members can read everything. The boundary is on writes, not visibility — every member sees every task and every message regardless of lane. Two teams that need to not see each other's work need two databases, not two lane permissions.
  • Asking is not doing. Any member can raise a task in any lane; the restriction is on claiming and writing to it, because blocking the ask would make it impossible to hand specialized work to someone qualified for it.
  • A leaked DSN is a leaked identity, exactly the way a leaked password is — it carries a live credential, not just a username, and the fix is the same as any credential leak: revoke, then reissue.
  • Operators are fully trusted. Anyone granted --admin can act on any lane and any member's rows. That access should go to the keeper, the drainer, and as few humans as the team can get away with.
  • The audit log records writes, not intent. It will tell you precisely that a given role moved a given task to blocked at a given timestamp. It cannot tell you why, because "why" was never a database write.

None of that is hidden in fine print — it is stated as plainly in the project's own team-mode documentation as the guarantees are, which is the right way to publish a security boundary: say what it buys you and say what it doesn't in the same breath, so nobody discovers the second half under pressure.


The full pattern: things that fail while reporting success →  ·  Why atomic claims beat merge conflicts