Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This file is the single source of truth for autonomous maintenance. Read by Clau

## Targeted Security Follow-up

- [x] #389: persist verified user/org identity bindings, backfill recognized existing tenants without moving data, resolve dashboard tenant before reads/writes, and restore explicit Personal selection. Shared/unknown legacy ownership requires operator audit; see `docs/knowledge/organization-identity.md`.
- #389 verification: API 525/525, dashboard 12/12 + build, API typecheck, changed-file Biome, SDK examples, TS SDK 17 passed/2 skipped, Python 55 passed/2 skipped. PR CI must pass; do not merge or deploy.

- [x] #436 Part 1: preserve global domain uniqueness, return neutral cross-project errors, atomically reclaim never-verified pending/failed claims after 7 days, and document the one-owner rule with regression coverage.
- [x] #436 Part 2: verify existing JSON-path hardening from #441; no implementation changes.
- PR review/green CI required before merge; do not merge this follow-up or release-please #442 as part of this task.
Expand Down
19 changes: 16 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
| [ClickHouse Monitoring](integrations/clickhouse-monitoring.md) | Use AgentState as the conversation-history backend for the clickhouse-monitoring dashboard |
| [Environment Variables](environment-variables.md) | Env vars and Cloudflare bindings |
| [Data Handling & Ownership](data-handling.md) | What data is stored, export, deletion controls, retention, and self-hosting |
| [Organization Identity](knowledge/organization-identity.md) | Stable tenant bindings, Personal workspaces, migration audit, and operator-only legacy recovery |
| [Core Memory](knowledge/core-memory.md) | Durable maintenance notes for future agents |
| [Workers Cache](knowledge/workers-cache.md) | Cloudflare Workers Cache: what's enabled, which public endpoints are cached, why authed routes are not |
| [Recipe: Leases](recipes/leases.md) | Distributed locking — coordinate N agents with exactly-one-writer semantics |
Expand Down
2 changes: 2 additions & 0 deletions docs/knowledge/core-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Durable notes for recurring maintenance. Keep this file small and update it inst

## Review Memory

- Dashboard tenancy resolves verified Clerk user/organization principals through persisted `organization_identities` to stable internal IDs. Never authorize by independently rebuilding `clerk_org_id` strings or auto-share Personal projects on organization attach. Keep Personal selectable; audit unbound legacy rows and require verified operator ownership before recovery. See [Organization Identity](organization-identity.md).

- Historical API-doc review notes from March 2026 were folded into the live docs. Keep API endpoint coverage current in `docs/api-reference.md`, `docs/sdk.md`, and `docs/integration.md`.
- Historical test-coverage notes were stale after the test suite expanded. Use current `packages/api/test/` coverage and CI output as the source of truth before adding tests.
- Recent state-platform maintenance should cover sparse `/api/v1/states/query` filters. Tag and JSON-path queries must keep scanning past nonmatching rows instead of stopping at the first capped candidate page.
Expand Down
98 changes: 98 additions & 0 deletions docs/knowledge/organization-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Organization identity and recovery

## Identity contract

Dashboard authentication resolves a verified Clerk principal once to a persisted
`organization_identities` binding. A principal is either a Clerk user (Personal)
or the active Clerk organization. The binding points to `organizations.id`, which
is the stable tenant ID used by project creation, reads, and authorization.
`organizations.clerk_org_id` remains a compatibility/display field, not an
independently re-derived authorization key.

The first authenticated request establishes a new principal's tenant, even when
it is a read. Creation is atomic and conflict-safe. Existing bindings are reused;
organization name sync cannot change identity. JWT organization claims are
normalized across Clerk versions; malformed or conflicting claims are rejected,
not interpreted as Personal.

Personal and team workspaces remain separate. The workspace selector always
includes **Personal** and never auto-activates a membership. Switching back to
Personal clears the Clerk active organization and restores access to the same
personal projects. Joining a team does not share personal projects with its
members. Identical project slugs in different workspaces are valid.

## Migration

The identity migration binds recognized `org_<alphanumeric>` and
`personal:user_<alphanumeric>` values to their **existing** internal organization
IDs. It does not move projects, rewrite keys, or delete rows. Descendant data and
API-key access remain unchanged.

Shared `default`, malformed values, and unknown historical formats have no
provable owner in the schema. They remain unbound. An exact legacy row encountered
without a binding causes `IDENTITY_CONFLICT`, rather than adopting it or returning
a newly created empty tenant. An unrecognized legacy row with a different value
cannot be attributed to a login automatically: deployment must include the audit
below. New identity conventions require an explicit migration, not a fallback
change in session verification.

## Deployment audit (read-only)

Run after applying migrations, before accepting the deployment as complete:

```sql
SELECT o.id, o.clerk_org_id, o.name, COUNT(p.id) AS project_count
FROM organizations o
LEFT JOIN organization_identities i ON i.organization_id = o.id
LEFT JOIN projects p ON p.org_id = o.id
WHERE i.organization_id IS NULL
GROUP BY o.id, o.clerk_org_id, o.name;
```

Every returned production row needs an operator disposition. Do not treat an
empty dashboard as evidence that data was deleted. Do not print query results
containing tenant information into public CI logs or issue comments.

## Operator-only recovery

There is intentionally no browser endpoint for claiming an unbound organization.
The historical shared `default` tenant may contain data from multiple people;
neither a current session, an organization name, nor membership proves ownership.

1. Back up the database and record the existing organization ID and project IDs.
2. Establish ownership independently using trustworthy historical records and
Clerk administration. If ownership is mixed or cannot be proven, stop. A
reviewed per-project recovery is necessary; never assign the shared row to the
next person who signs in.
3. Audit both sides using the **verified** principal kind and Clerk subject:

```sql
SELECT * FROM organization_identities
WHERE organization_id = :existing_organization_id
OR (principal_kind = :verified_kind AND clerk_subject = :verified_subject);
```

4. Only if the row is unbound **and** the destination principal is unbound, insert
the explicitly reviewed mapping. Use bound SQL parameters with an operator
database client; the names below are placeholders, not values to paste:

```sql
INSERT INTO organization_identities
(principal_kind, clerk_subject, organization_id)
VALUES (:verified_kind, :verified_subject, :existing_organization_id);
```

Unique constraints refuse competing principal/tenant mappings. Never use
`REPLACE`, delete a conflicting binding, or overwrite a live destination. If
the destination already has a tenant, stop for a separate reviewed merge that
checks project slug collisions and retains data ownership. Re-running a repair
should first confirm the exact mapping already exists, then make no change.
5. Re-run the audit. Verify project IDs and API-key behavior are unchanged, the
verified owner can read the data, and an unrelated session cannot. Retain a
private audit record of the approved mapping and verification.

For rollback, stop dashboard writes and restore the known-good backup or revert
only a newly inserted mapping after verifying it is still exactly the reviewed
mapping and no new activity depends on it. Do not delete organizations or project
children. Do not roll back application code to string-derived authorization
without checking every compatibility field against its binding first.
24 changes: 24 additions & 0 deletions packages/api/drizzle/0012_mixed_monster_badoon.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
CREATE TABLE `organization_identities` (
`principal_kind` text NOT NULL,
`clerk_subject` text NOT NULL,
`organization_id` text NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "organization_identities_kind_check" CHECK("organization_identities"."principal_kind" IN ('user', 'organization'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `organization_identities_principal_idx` ON `organization_identities` (`principal_kind`,`clerk_subject`);--> statement-breakpoint
CREATE UNIQUE INDEX `organization_identities_organization_idx` ON `organization_identities` (`organization_id`);
--> statement-breakpoint
-- Bind only recognizable historical identities, preserving internal tenant IDs.
-- Shared default and unknown formats require verified operator recovery.
INSERT INTO organization_identities (principal_kind, clerk_subject, organization_id)
SELECT 'user', substr(clerk_org_id, 10), id FROM organizations
WHERE clerk_org_id GLOB 'personal:user_*'
AND length(substr(clerk_org_id, 15)) > 0
AND substr(clerk_org_id, 15) NOT GLOB '*[^A-Za-z0-9]*';
--> statement-breakpoint
INSERT INTO organization_identities (principal_kind, clerk_subject, organization_id)
SELECT 'organization', clerk_org_id, id FROM organizations
WHERE clerk_org_id GLOB 'org_*'
AND length(substr(clerk_org_id, 5)) > 0
AND substr(clerk_org_id, 5) NOT GLOB '*[^A-Za-z0-9]*';
Loading