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
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ A coworker is a durable Bot profile:

A channel is a conversation with one coworker and a CopilotKit Intelligence thread mapping. Starting a new channel creates a new thread.

Who may reach one is decided by membership: every channel route resolves the caller in
`channel_memberships` and refuses without a row. `channels.allowed_groups` is declared in the
tenant package and stored, and is not part of that decision — `users.groups` is never populated by
any sign-in path, so a group-based rule has nothing to evaluate. Treat it as a declaration waiting
on group membership from the identity provider, not as a control that is running.

See [coworkers.md](coworkers.md).

## Components
Expand Down
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,18 @@ channels:

Each channel requires `id`, `name`, `description`, `permitted_agents`, and `allowed_groups`. Every `permitted_agents` entry must match an agent id.

`allowed_groups` is validated and stored, and nothing reads it. It decides nothing today, and a
deployment that writes one must not treat it as an access control. Both halves of that control are
missing, not one: `users.groups` exists as a column and no sign-in path, claim mapping or admin
screen ever populates it, so there is nothing for a channel's list to be compared against. Channel
access is decided by membership alone — every channel route resolves the caller's row in
`channel_memberships` and refuses without it.

Package-declared channels get no membership rows from `synchronizeTenantPackage`, so today they
are unreachable rather than open. The field is kept because the enforcement it is named for needs
the declaration and needs group membership arriving from the identity provider, and neither this
column nor `users.groups` is the wrong shape for it.

### `model.yaml`

```yaml
Expand Down
18 changes: 18 additions & 0 deletions server/src/db/schema/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export const users = pgTable("users", {
name: text("name"),
image: text("image"),
emailVerified: boolean("email_verified").notNull().default(false),
/**
* The person's groups, for a group-based rule to be evaluated against.
*
* Empty on every row: no sign-in path, claim mapping or admin screen writes this, and nothing
* reads it. It is the other half of `channels.allowedGroups`, and #82 is about the pair. Anything
* that starts deciding access on a group has to populate this first, or it decides on an empty
* list for everybody.
*/
groups: text("groups").array().notNull().default([]),
createdAt: createdAt(),
updatedAt: updatedAt(),
Expand Down Expand Up @@ -221,6 +229,16 @@ export const channels = pgTable(
name: text("name").notNull(),
description: text("description").notNull(),
suggestedPrompts: text("suggested_prompts").array().notNull().default([]),
/**
* Which groups the tenant package says this channel is for.
*
* Written by `synchronizeTenantPackage` and read by nothing. Channel access is membership: every
* route resolves the caller in `channelMemberships` and refuses without a row, and this column is
* not consulted on the way. It is not currently a hole, because package channels get no
* membership rows either and so are unreachable rather than open, but it is a control the name
* promises and nothing keeps. See #82, and `users.groups`, which is the half that has to arrive
* from the identity provider before this one can decide anything.
*/
allowedGroups: text("allowed_groups").array().notNull().default([]),
packageId: uuid("package_id").references(() => deploymentPackages.id, {
onDelete: "set null",
Expand Down