From 9ec68cf8d8b42b4ea9213fd26b3d5ff681b49fcb Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 12 May 2026 07:31:02 +0000 Subject: [PATCH 1/2] docs: add read-only access skill documenting isReadOnly memberships and read-only API keys --- .agents/skills/read-only-access/SKILL.md | 131 +++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 .agents/skills/read-only-access/SKILL.md diff --git a/.agents/skills/read-only-access/SKILL.md b/.agents/skills/read-only-access/SKILL.md new file mode 100644 index 000000000..6753b9750 --- /dev/null +++ b/.agents/skills/read-only-access/SKILL.md @@ -0,0 +1,131 @@ +--- +name: read-only-access +description: Read-only access control — membership-level is_read_only and API key access_level for restricting mutations +--- + +# Read-Only Access + +Constructive provides two complementary mechanisms for read-only access control. They serve different personas and can be stacked for defense in depth. + +## 1. Read-Only Memberships (`isReadOnly`) + +Mark an entity-scoped membership as read-only to block all mutations (INSERT, UPDATE, DELETE) for that member within that entity's tables, while allowing full SELECT access. + +### How It Works + +- Every entity-scoped membership (orgs, groups, data rooms, channels — `membership_type >= 2`) has an `isReadOnly` boolean field on both the memberships table and the SPRT (Security Policy Resolution Table). +- When `isReadOnly` is `true`, an auto-generated **restrictive RLS policy** (`AuthzNotReadOnly`) blocks all mutation privileges. Since PostgreSQL ANDs restrictive policies with permissive ones, the member's normal permissions still grant SELECT but all writes are denied. +- Owners and admins cannot be set to read-only — trigger guards prevent `isReadOnly = true` when `isOwner = true` or `isAdmin = true`. + +### SDK Usage + +```bash +# Invite a member as read-only +csdk org-membership create --actorId --entityId --isReadOnly true + +# Update an existing member to read-only +csdk org-membership update --id --isReadOnly true + +# Remove read-only restriction +csdk org-membership update --id --isReadOnly false +``` + +### GraphQL + +```graphql +mutation { + createOrgMembership(input: { + actorId: "user-uuid" + entityId: "org-uuid" + isReadOnly: true + }) { + orgMembership { + id + isReadOnly + } + } +} +``` + +### Behavior + +| Action | Read-Only Member | Normal Member | +|--------|-----------------|---------------| +| SELECT (read data) | Allowed | Allowed | +| INSERT (create records) | Blocked by RLS | Allowed (if permitted) | +| UPDATE (modify records) | Blocked by RLS | Allowed (if permitted) | +| DELETE (remove records) | Blocked by RLS | Allowed (if permitted) | + +### Scope + +- Applies to **all entity-scoped tables** for that entity (any table with an `AuthzEntityMembership` policy) +- One restrictive policy per table — automatically injected during table provisioning +- If a table has mixed-scope policies (e.g., both `AuthzEntityMembership` and `AuthzDirectOwner`), read-only still blocks all mutations for the entity scope + +## 2. Read-Only API Keys (`accessLevel`) + +Create an API key with `accessLevel: 'read_only'` to make the entire transaction read-only at the PostgreSQL level. The key physically cannot perform any writes, regardless of the user's permissions. + +### How It Works + +- The `session_credentials` table has an `accessLevel` field (default: `'full_access'`). +- When a request authenticates with a credential where `accessLevel = 'read_only'`, the server sets `default_transaction_read_only = 'on'` via `pgSettings`. PostgreSQL then rejects any write operation in that transaction with: `ERROR: cannot execute INSERT in a read-only transaction`. +- This is enforced by the PostgreSQL engine itself — no RLS policy, trigger, or function can bypass it. + +### SDK Usage + +```bash +# Create a read-only API key +csdk create-api-key --input.keyName "my-readonly-key" --input.accessLevel "read_only" + +# Create a normal (full access) API key +csdk create-api-key --input.keyName "my-key" --input.accessLevel "full_access" +``` + +### GraphQL + +```graphql +mutation { + createApiKey(input: { + keyName: "my-readonly-key" + accessLevel: "read_only" + }) { + apiKey + accessLevel + } +} +``` + +### Behavior + +Any request authenticated with a read-only API key: +- Can execute any SELECT / read query +- Cannot execute INSERT, UPDATE, DELETE, CREATE, DROP, or any other write operation +- Receives a PostgreSQL error if a write is attempted: `cannot execute INSERT in a read-only transaction` + +### Access Level Values + +| Value | Description | +|-------|-------------| +| `full_access` | Default. Normal read + write access (subject to RLS policies). | +| `read_only` | Transaction-level read-only. All writes rejected by PostgreSQL. | + +## How They Complement Each Other + +| Scenario | Read-Only Membership | Read-Only API Key | +|----------|---------------------|-------------------| +| Org admin invites a viewer | Member can read but not mutate in that org | N/A | +| Developer creates a safe integration key | N/A | Key cannot write anything, period | +| Contractor with read-only org access | Can't mutate in that org, can still write in other orgs | Personal keys still work normally elsewhere | +| Read-only dashboard service | N/A | App-wide read-only key reads everything, writes nothing | +| Defense in depth | Read-only member + read-only API key | Both layers enforced independently | + +- **Read-Only Membership** = per-entity, per-member. Managed by org admins via the membership API. +- **Read-Only API Key** = per-session, per-key. Self-service by developers via the API key creation API. + +## Performance + +Both mechanisms have negligible performance impact: + +- **Read-only membership**: The restrictive policy checks `isReadOnly IS NOT TRUE` on a SPRT row already fetched by the permissive policy. Cost: <0.01ms per mutation. Zero impact on SELECT queries. +- **Read-only API key**: `default_transaction_read_only` is a PostgreSQL GUC checked by the executor. No additional queries or index lookups. From 7f1324ff26a39d8e761dca4dcb789bc106af84f1 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 12 May 2026 07:35:57 +0000 Subject: [PATCH 2/2] docs: fix read-only skill - clarify invite flow, remove internal references (SPRT, AuthzNotReadOnly, pgSettings) --- .agents/skills/read-only-access/SKILL.md | 45 +++++++++++++++++------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/.agents/skills/read-only-access/SKILL.md b/.agents/skills/read-only-access/SKILL.md index 6753b9750..ffaa8e7a6 100644 --- a/.agents/skills/read-only-access/SKILL.md +++ b/.agents/skills/read-only-access/SKILL.md @@ -13,26 +13,45 @@ Mark an entity-scoped membership as read-only to block all mutations (INSERT, UP ### How It Works -- Every entity-scoped membership (orgs, groups, data rooms, channels — `membership_type >= 2`) has an `isReadOnly` boolean field on both the memberships table and the SPRT (Security Policy Resolution Table). -- When `isReadOnly` is `true`, an auto-generated **restrictive RLS policy** (`AuthzNotReadOnly`) blocks all mutation privileges. Since PostgreSQL ANDs restrictive policies with permissive ones, the member's normal permissions still grant SELECT but all writes are denied. +- Every entity-scoped membership (orgs, groups, data rooms, channels, etc.) has an `isReadOnly` boolean field. +- When `isReadOnly` is `true`, a restrictive policy blocks all mutation privileges. The member's normal permissions still grant SELECT but all writes are denied. - Owners and admins cannot be set to read-only — trigger guards prevent `isReadOnly = true` when `isOwner = true` or `isAdmin = true`. ### SDK Usage -```bash -# Invite a member as read-only -csdk org-membership create --actorId --entityId --isReadOnly true +The invite system (`org-invite create` / `submit-org-invite-code`) does not currently support setting `isReadOnly` at invite time. New members join with `isReadOnly = false` by default. To make a member read-only, update their membership after they join: -# Update an existing member to read-only +```bash +# Update an existing member to read-only (admin/owner only) csdk org-membership update --id --isReadOnly true # Remove read-only restriction csdk org-membership update --id --isReadOnly false ``` +Direct membership creation (admin/owner only, bypasses the invite flow): + +```bash +csdk org-membership create --actorId --entityId --isReadOnly true +``` + ### GraphQL ```graphql +# Update a member to read-only +mutation { + updateOrgMembership(input: { + id: "membership-uuid" + patch: { isReadOnly: true } + }) { + orgMembership { + id + isReadOnly + } + } +} + +# Direct creation (admin/owner only) mutation { createOrgMembership(input: { actorId: "user-uuid" @@ -58,9 +77,9 @@ mutation { ### Scope -- Applies to **all entity-scoped tables** for that entity (any table with an `AuthzEntityMembership` policy) +- Applies to **all entity-scoped tables** for that entity - One restrictive policy per table — automatically injected during table provisioning -- If a table has mixed-scope policies (e.g., both `AuthzEntityMembership` and `AuthzDirectOwner`), read-only still blocks all mutations for the entity scope +- If a table has mixed-scope policies, read-only still blocks all mutations for the entity scope ## 2. Read-Only API Keys (`accessLevel`) @@ -68,9 +87,9 @@ Create an API key with `accessLevel: 'read_only'` to make the entire transaction ### How It Works -- The `session_credentials` table has an `accessLevel` field (default: `'full_access'`). -- When a request authenticates with a credential where `accessLevel = 'read_only'`, the server sets `default_transaction_read_only = 'on'` via `pgSettings`. PostgreSQL then rejects any write operation in that transaction with: `ERROR: cannot execute INSERT in a read-only transaction`. -- This is enforced by the PostgreSQL engine itself — no RLS policy, trigger, or function can bypass it. +- API keys have an `accessLevel` field (default: `'full_access'`). +- When a request authenticates with a credential where `accessLevel = 'read_only'`, the server enforces a read-only transaction. PostgreSQL then rejects any write operation with: `ERROR: cannot execute INSERT in a read-only transaction`. +- This is enforced at the PostgreSQL engine level — no policy, trigger, or function can bypass it. ### SDK Usage @@ -127,5 +146,5 @@ Any request authenticated with a read-only API key: Both mechanisms have negligible performance impact: -- **Read-only membership**: The restrictive policy checks `isReadOnly IS NOT TRUE` on a SPRT row already fetched by the permissive policy. Cost: <0.01ms per mutation. Zero impact on SELECT queries. -- **Read-only API key**: `default_transaction_read_only` is a PostgreSQL GUC checked by the executor. No additional queries or index lookups. +- **Read-only membership**: The restrictive policy reuses data already fetched by the permissive policy. Zero additional queries. Zero impact on SELECT queries. +- **Read-only API key**: Enforced by the PostgreSQL executor with no additional queries or index lookups.