Access Control Reference
Technical reference for the Gateway ACL model — principals, roles, group resolution, and D1 schema.
For the non-technical guide, see Sharing & Permissions.
Two-Role Model
Gateway uses a flat two-role model. No hierarchies, no granular permissions.
| Role | Procedures | Runs |
|---|---|---|
| Admin | Full read/write. Edit document, manage access, view all runs, trigger exports. | See all runs for their procedures. Full data. |
| User | Discover (if public or shared). Invoke (if public or shared). Cannot read procedure internals. | See own runs (creator). See assigned runs (from assignment step onward). |
Principals
A principal is an identity that can be granted access. Three formats:
| Format | Example | Resolves to |
|---|---|---|
slack:#channel | slack:#finance-team | All members of the Slack channel |
email:addr | email:alice@co.com | Single user, matched by email across providers |
user:id | user:abc123 | Single user by Gateway ID |
Slack channels as groups
Slack channels are the primary group primitive. Membership is resolved dynamically via the Slack API and cached in D1 with a 1-hour TTL.
-- group_cache table
CREATE TABLE group_cache (
group_ref TEXT NOT NULL, -- e.g. "slack:#finance-team"
user_email TEXT NOT NULL,
resolved_at TEXT NOT NULL,
PRIMARY KEY (group_ref, user_email)
);Resolution flow:
- ACL check needs to evaluate
slack:#finance-team - Check
group_cachefor entries whereresolved_atis within 1 hour - If cache miss or stale → call Slack API
conversations.members+users.infoto resolve emails - Upsert cache entries
- Check if the user's email is in the resolved set
Cross-provider identity
Email is the join key. A user connects Notion, Linear, and Slack with potentially different usernames, but the same email links them. Gateway stores provider_user_email in linked_accounts for this mapping.
Procedure Access
Stored in D1 procedure_access table:
CREATE TABLE procedure_access (
procedure_id TEXT NOT NULL,
principal TEXT NOT NULL, -- "slack:#finance", "email:a@b.com", "user:xyz"
principal_type TEXT NOT NULL, -- "slack_channel", "email", "user"
role TEXT NOT NULL, -- "admin" or "user"
is_creator INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (procedure_id, principal)
);Sync from frontmatter
When a procedure document is saved, syncProcedureAccess reads the frontmatter and upserts the procedure_access table:
frontmatter.admins → role: "admin"
frontmatter.share → role: "user"
procedure creator → role: "admin", is_creator: 1Check flow
canDiscover(userId, procedureId)
→ procedure public? yes → true
→ user is admin? yes → true
→ user in share list (direct or via group)? yes → true
→ false
canReadProcedure(userId, procedureId)
→ user is admin? yes → true
→ false (users can discover/invoke but not read internals)Run Access
Stored in D1 run_access table:
CREATE TABLE run_access (
run_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL, -- "creator" or "assigned"
assigned_at_step TEXT, -- which step triggered assignment
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (run_id, user_id)
);Lifecycle
- Run created → creator gets
role: "creator"inrun_access - Step with assignment → assigned user gets
role: "assigned"withassigned_at_step - ACL check →
canAccessRunchecksrun_access+ procedure admin status
canAccessRun(userId, runId)
→ user in run_access? yes → true
→ user is admin of the run's procedure? yes → true
→ false
canCancelRun(userId, runId)
→ user is run creator? yes → true
→ user is admin of the run's procedure? yes → true
→ falseD1 Tables Summary
| Table | Purpose |
|---|---|
procedure_access | Who can discover/invoke/admin a procedure |
run_access | Who can see a specific run (creator + assigned) |
group_cache | Cached Slack channel membership (1h TTL) |
runs | Run state, procedure snapshot, wait metadata |
run_submissions | Data collected during execution steps |
run_log | Execution log entries |
slack_workspace | Bot token per Slack workspace |
API Functions
acl.ts
| Function | Purpose |
|---|---|
hasRole(userId, procedureId, role, db) | Check if user has a specific role on a procedure |
canDiscover(userId, procedureId, db) | Can user find this procedure? |
canReadProcedure(userId, procedureId, db) | Can user read procedure internals? (admin only) |
canAccessRun(userId, runId, db) | Can user see this run? |
canCancelRun(userId, runId, db) | Can user cancel this run? |
grantRunAccess(runId, userId, role, step, db) | Add user to run's access list |
resolveGroup(groupRef, resolver, db) | Resolve Slack channel → emails (with cache) |
syncProcedureAccess(procedureId, frontmatter, creatorId, db) | Sync frontmatter → procedure_access table |
runs.ts
| Function | Purpose |
|---|---|
createRun(...) | Create run + grant creator access |
loadRun(runId, userId, db) | Load run (ACL-gated) |
submitData(runId, step, data, userId, db) | Record step submission (ACL-gated) |
logStep(runId, step, entry, autonomous, db) | Append to execution log |
parkRun(runId, waitUntil, waitContext, db) | Set status to waiting |
completeRun(runId, db) | Set status to completed |
cancelRun(runId, userId, db) | Cancel run (ACL-gated) |
listRuns(procedureId, userId, db) | List runs user can see |