OpenWork Den Control Plane Architecture for Team and Organization Management
The OpenWork Den control plane uses a three-layer architecture—MySQL persistence with Drizzle ORM, a Hono-based REST API, and a React admin UI—to securely manage multi-tenant organizations, teams, members, and roles.
OpenWork Den is the backend control plane that stores and enforces all multi-tenant data for an OpenWork deployment, including organizations, teams, members, invitations, roles, and related policies. Its architecture is deliberately split into three distinct layers that work together to provide secure, scalable team and organization management. This article examines each layer, the key data models, and the business rules enforced throughout the system.
Three-Layer Architecture Overview
The OpenWork Den control plane architecture consists of persistence, API service, and web UI layers. Each layer has a clearly defined responsibility and communicates through well-defined interfaces.
Persistence Layer (den-db)
The persistence layer stores the canonical state of all control plane entities using a MySQL database. The schema lives in the den-db package and is built with Drizzle ORM.
Key tables include:
organization– org metadata, slug, branding, email-domain allow-list, and desktop-app restrictionsteam– team definitions with organization-scoped slugsmember– links users to organizations with optional team assignmentinvitation– pending invites with tokens, roles, expiry, and optionalteam_idorganization_role– custom roles with JSON permission definitionsinstall_link– one-time tokens for desktop client organization installation
The full schema source is available in [src/schema/org.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/schema/org.ts), which defines OrganizationTable, MemberTable, InvitationTable, and related structures.
API Service Layer (den-api)
The API service exposes HTTP endpoints that the desktop app, web UI, and MCP clients consume. Implemented as a Hono server inside the den-api package, this layer validates Org-scoped permissions, translates requests into Drizzle queries, and returns JSON responses.
Business rules enforced at this layer include:
- Only org admins can create or delete teams
- Invitations must belong to the same org and may target a specific team
- Desktop hand-off and connect-grant tables enforce short-lived tokens for secure desktop hand-off
The Docker image for this service is built by docker/den-api.
Web UI Layer (den-web)
The Web UI provides the admin console (/admin) where organization owners manage teams, members, roles, and branding. This React + TanStack Query application consumes the same TypeScript types returned by the API layer and surfaces policies like desktop_app_restrictions.
UI code lives under apps/den-web, with components triggering the API endpoints described above.
Team Data Model
Teams are a first-class entity attached to organizations. The team table in [src/schema/teams.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/schema/teams.ts) stores organization-scoped team definitions:
export const TeamTable = mysqlTable(
"team",
{
id: denTypeIdColumn("team", "id").notNull().primaryKey(),
organizationId: denTypeIdColumn("organization", "organization_id").notNull(),
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(),
metadata: json("metadata").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { fsp: 3 })
.notNull()
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
},
(table) => [
uniqueIndex("team_slug_org").on(table.organizationId, table.slug),
],
);
Key design choices:
organizationIdforeign key ensures teams are strictly scoped to one organizationslugcombined withorganizationIdforms a unique index for URL-friendly team identifiersmetadataJSON field allows extensible custom attributes without schema migrations
Members optionally reference a team_id, and invitations can carry a team_id so new members automatically join the specified team upon acceptance.
Business Rules and Policy Enforcement
The API layer enforces critical security and consistency rules for team and organization management.
| Rule | Enforcement Location |
|---|---|
| Org-admin role required for team CRUD operations | src/api/team.ts – validates member.role against organization_role |
| Team-scoped invitations auto-assign on acceptance | src/api/invitation.ts – validates team.organization_id matches invitation's organization_id |
| Desktop hand-off tokens expire after short TTL | src/schema/desktop-handoff-grant.ts – API validates expires_at field |
| Custom permissions interpreted by policy engine | src/policy/role-policy.ts – parses JSON from organization_role.permission |
Practical Code Examples
Creating an Organization
import { db } from "@/den-db/src/client";
import { organization } from "@/den-db/src/schema/org";
// Insert an org with a slug and default desktop restrictions
await db.insert(organization).values({
id: "org_01", // generated Den TypeID
name: "Acme Corp",
slug: "acme",
allowedEmailDomains: ["acme.com"],
desktopAppRestrictions: {}, // empty JSON = no extra restrictions
});
Adding a Team via API
// POST /v1/orgs/:orgSlug/teams
await fetch(`${BASE_URL}/v1/orgs/acme/teams`, {
method: "POST",
headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
body: JSON.stringify({ name: "Engineering", slug: "engineering" }),
});
Inviting a User to a Specific Team
await fetch(`${BASE_URL}/v1/orgs/acme/invitations`, {
method: "POST",
headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
body: JSON.stringify({
email: "bob@acme.com",
role: "member",
teamId: "team_02", // optional – attaches invite to team
expiresAt: "2027-01-01T00:00:00Z",
}),
});
Fetching Teams in the Frontend
const { data: teams } = useQuery(["org", orgSlug, "teams"], () =>
fetch(`${BASE_URL}/v1/orgs/${orgSlug}/teams`).then((r) => r.json())
);
Key Source Files
| Component | Path |
|---|---|
| Core schema (organizations, members, invitations) | [ee/packages/den-db/src/schema/org.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/schema/org.ts) |
| Team table definition | [ee/packages/den-db/src/schema/teams.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/schema/teams.ts) |
| Desktop hand-off grant table | [ee/packages/den-db/src/schema/desktop-handoff-grant.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/schema/desktop-handoff-grant.ts) |
| Drizzle client setup | [ee/packages/den-db/src/client.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/client.ts) |
| Organization API routes | ee/apps/den-api/src/routes/org.ts |
| Team API routes | ee/apps/den-api/src/routes/team.ts |
| Admin UI components | apps/den-web/src/pages/admin/ |
| Helm deployment chart | packaging/helm/openwork-ee/README.md |
Summary
- Three-layer architecture separates concerns: Drizzle ORM persistence, Hono REST API, and React admin UI
- Strict organization scoping ensures multi-tenant isolation through foreign keys and composite unique indexes
- Teams as first-class entities with slugs, metadata, and automatic member assignment via invitations
- Policy enforcement happens at the API layer, checking roles and permissions before any database mutation
- Type-safe end-to-end implementation uses shared TypeScript types across database, API, and frontend
Frequently Asked Questions
How does OpenWork Den ensure teams remain isolated between organizations?
The team table enforces isolation through a composite unique index on (organizationId, slug) and foreign key constraints. Every team row is explicitly tied to one organization, and the API layer validates that all operations are performed by members of that same organization according to [src/schema/teams.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/schema/teams.ts).
What database migrations are used for the control plane schema?
The schema uses Drizzle ORM with its migration system defined in the den-db package. Migrations are generated from the TypeScript schema files and applied to the MySQL database. The Drizzle client configuration is located in [src/client.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/client.ts).
Can custom roles define organization-specific permissions?
Yes. The organization_role table stores custom role definitions with a flexible permission JSON column. The policy engine in src/policy/role-policy.ts parses this JSON to make authorization decisions, allowing organizations to define granular access controls beyond the default admin/member roles.
How are desktop clients securely connected to organizations?
Desktop hand-off uses short-lived tokens stored in the desktop_handoff_grant table as defined in [src/schema/desktop-handoff-grant.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/schema/desktop-handoff-grant.ts). These tokens are scoped to an organization, expire quickly, and are consumed once to establish the desktop client's session.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →