# OpenWork Den Control Plane Architecture for Team and Organization Management

> Explore the OpenWork Den control plane architecture for managing organizations and teams. Discover its three-layer design: MySQL persistence Drizzle ORM, Hono REST API, and React admin UI.

- Repository: [Different AI/openwork](https://github.com/different-ai/openwork)
- Tags: architecture
- Published: 2026-08-15

---

**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 restrictions
- **`team`** – team definitions with organization-scoped slugs
- **`member`** – links users to organizations with optional team assignment
- **`invitation`** – pending invites with tokens, roles, expiry, and optional `team_id`
- **`organization_role`** – custom roles with JSON permission definitions
- **`install_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/main/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/main/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:

```typescript
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:**

- **`organizationId`** foreign key ensures teams are strictly scoped to one organization
- **`slug`** combined with `organizationId` forms a unique index for URL-friendly team identifiers
- **`metadata`** JSON 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`](https://github.com/different-ai/openwork/blob/main/src/api/team.ts) – validates `member.role` against `organization_role` |
| Team-scoped invitations auto-assign on acceptance | [`src/api/invitation.ts`](https://github.com/different-ai/openwork/blob/main/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`](https://github.com/different-ai/openwork/blob/main/src/schema/desktop-handoff-grant.ts) – API validates `expires_at` field |
| Custom permissions interpreted by policy engine | [`src/policy/role-policy.ts`](https://github.com/different-ai/openwork/blob/main/src/policy/role-policy.ts) – parses JSON from `organization_role.permission` |

## Practical Code Examples

### Creating an Organization

```typescript
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

```typescript
// 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

```typescript
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

```typescript
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/main/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/main/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/main/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/main/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`](https://github.com/different-ai/openwork/blob/main/ee/apps/den-api/src/routes/org.ts) |
| Team API routes | [`ee/apps/den-api/src/routes/team.ts`](https://github.com/different-ai/openwork/blob/main/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`](https://github.com/different-ai/openwork/blob/main/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/main/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/main/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`](https://github.com/different-ai/openwork/blob/main/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/main/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.