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
20 changes: 20 additions & 0 deletions .changeset/gateway-account-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@etus/bhono": minor
---

feat(auth): consume gateway per-account roles (@etus/auth 0.9.1)

The template now reads the gateway's per-account roles (viewer < editor < manager <
admin, Auth0-Organizations model) for authorization, alongside its own local
workspaces (hybrid model — member management stays local):

- Bumps `@etus/auth` 0.8.0 → 0.9.1.
- `ACCOUNT_ROLE_MAP` (`src/server/auth/matrix.ts`) + `gatewayAuthority.accountRoleMap`
map gateway account roles → local permissions (unioned across the user's accounts).
- `GET /api/me` exposes the user's gateway accounts + super-admin flag (safe empty
shape when gatewayAuthority is off).
- `useGatewayAccounts()` client hook with `hasAccountRole(slug, role)` for UI gating.
- `auth.requireGatewayAccountRole(slug, role)` is available for precise per-account
server-side gating.

See the "Papéis por-conta do gateway" section in `docs/SETUP-GUIDE.md`.
10 changes: 3 additions & 7 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,9 @@
"includeCoAuthoredBy": false,
"permissions": {
"allow": [
"Edit:*",
"Write:*",
"MultiEdit:*",
"NotebookEdit:*",
"Bash:*",
"WebSearch:*",
"WebFetch:*",
"Edit",
"Write",
"NotebookEdit",
"Bash(grep:*)",
"Bash(diff:*)",
"Bash(echo:*)",
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dist/
.wrangler/
.dev.vars
docs/openapi.json
seed.sql

# Test outputs
.test-output/
Expand Down
12 changes: 11 additions & 1 deletion .dev.vars.example → config/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Local Worker variables used by `pnpm dev`.
# Local Worker variables used by `pnpm dev`. Copy this to `config/.dev.vars`.
#
# This file MUST live next to the Wrangler config (config/wrangler.json): wrangler
# and @cloudflare/vite-plugin only read `.dev.vars` from the config file's directory,
# so a `.dev.vars` at the repo root is ignored by `pnpm dev`.

ENVIRONMENT=development
APP_URL=http://localhost:8787
Expand All @@ -19,6 +23,12 @@ ETUS_ADMIN_EMAILS=admin@yourdomain.com
# ETUS_RESOURCE_ID=yourapp.example.com
# ETUS_INTEGRATION_KEY=ag_app_yourapp_xxx.yyy

# DEV-ONLY gateway mock — serve the local multi-tenant scenario fixture
# (src/server/dev/gateway-scenario.ts) from /api/me so you can click through the
# Workspaces UI and per-account gateway roles without a live gateway. Log in via
# /auth/test-login as a scenario user (e.g. multi@example.com). Ignored in production.
# ETUS_GATEWAY_MOCK=1

# SendGrid
SENDGRID_API_KEY=your-sendgrid-api-key
SENDGRID_FROM_EMAIL=noreply@yourdomain.com
Expand Down
10 changes: 9 additions & 1 deletion config/wrangler.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,15 @@
"binding": "ASSETS",
"run_worker_first": [
"/api/*",
"/auth/*"
"/auth/*",
"/accounts",
"/accounts/*",
"/audit",
"/audit/*",
"/invitations",
"/invitations/*",
"/health",
"/health/*"
]
},
"vars": {
Expand Down
84 changes: 84 additions & 0 deletions docs/SETUP-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,90 @@ bloqueia; uma indisponibilidade transitória serve o cache (não derruba o app).
> (v0.6.0+ usa D1 via `createSqlSessionStore`); pode ser removido se nenhum outro
> código depender dele.

#### Papéis por-conta do gateway (@etus/auth v0.9.1)

Além dos **scopes** (`SCOPE_MAP`), o gateway resolve um **papel por conta** do
usuário (modelo Auth0 Organizations: `viewer < editor < manager < admin`, da
migration 0070 do gateway). Este boilerplate **lê** esses papéis para autorização
(camada org-level, ao lado dos workspaces locais) — a gestão de membros continua
**local** (modelo híbrido).

- **`ACCOUNT_ROLE_MAP`** (`src/server/auth/matrix.ts`) — paralelo ao `SCOPE_MAP`,
mapeia cada papel-por-conta do gateway → permissões locais (entradas do
`PERMISSION_CATALOG`). Ligado via `gatewayAuthority.accountRoleMap` em
`setup.ts`. O `@etus/auth` **une** essas permissões entre **todas** as contas do
usuário (super-admin conta como `admin` em todas) e injeta em `authPermissions`.
É um grant **coarse, org-level** — ajuste por produto.
- **Gating preciso por-conta** — para "manager nesta conta específica", use o guard
do pacote `auth.requireGatewayAccountRole(slug, role)` (não o mapa global). Um
super-admin sempre passa; lança `NotAccountMemberError`/`AccountRoleRequiredError`.
- **Contexto do usuário** — `GET /api/me` (`src/server/routes/me/index.ts`) devolve
`{ accounts: [{id,slug,name,role}], superAdmin }` resolvido pelo gateway. Vazio /
`false` quando `ETUS_GATEWAY_AUTHORITY` está off (shape sempre seguro).
- **No client** — o hook `useGatewayAccounts()`
(`src/client/hooks/use-gateway-accounts.ts`) lê o `/api/me` e expõe `accounts`,
`superAdmin` e `hasAccountRole(slug, role)` para gatear a UI:

```tsx
const { accounts, superAdmin, hasAccountRole } = useGatewayAccounts()

// badge de papel por conta do gateway:
{accounts.map((a) => <Badge key={a.id}>{a.name}: {a.role}</Badge>)}

// gating de UI — NÃO é fronteira de segurança (o guard do server é a autoridade):
{hasAccountRole('unum', 'manager') && <InviteButton accountSlug="unum" />}
```

> ⚠️ **Regras de segurança do `ACCOUNT_ROLE_MAP`** (é grant **org-level**: o pacote
> une as permissões entre **todas** as contas do usuário, sem escopo por-conta):
> - **Nunca** use `'*'` ou wildcard de namespace (`resources:*`) — um usuário que
> seja `admin` em **qualquer** conta (até uma não-relacionada) passaria esse guard
> no app inteiro. Mantenha valores **bounded e não-destrutivos** (sem `:delete`,
> `billing:manage`). Há um teste que falha se um wildcard entrar.
> - Para autz por conta/workspace **específico** (ex.: "admin DESTA conta pode
> deletá-la"), use `auth.requireGatewayAccountRole(slug, role)` no server e
> `hasAccountRole(slug, role)` no client — **não** este mapa.
> - As permissões aqui são **unidas** com as do papel local (aditivas): remover um
> usuário de uma conta **local** NÃO revoga o que o gateway concede. O gateway é a
> autoridade do que ele resolve; com gateway-authority ligado, a gestão de membros
> local não é um kill-switch de autorização.

#### Validar a UI multi-tenant localmente (gateway mock)

Os papéis por-conta do gateway vêm do gateway via HTTP — local não há gateway. Para
**validar a UI** (e escrever testes) sem um gateway ao vivo, há um **mock de dev**:

- **`src/server/dev/gateway-scenario.ts`** — cenário multi-tenant fixo, por e-mail
(alinhado ao `seed.ts`). O `/api/me`, quando o mock está ligado, resolve as contas
do gateway do usuário logado a partir desse fixture. Gated **duas vezes**:
`ENVIRONMENT !== 'production'` **e** `ETUS_GATEWAY_MOCK` truthy — nunca em produção.
- **Página `Workspaces`** (`src/client/routes/_authenticated/workspaces.tsx`, no nav)
— renderiza `useGatewayAccounts()`: banner de super-admin, um card por conta com o
badge de papel (viewer/editor/manager/admin) e o que ele concede; empty state.

Usuários do cenário (logue via `/auth/test-login`): `superadmin@example.com`
(super-admin), `admin@example.com` (admin em Acme), `multi@example.com` (**admin em
Initech + viewer em Acme** — o caso de over-grant cross-account que o `ACCOUNT_ROLE_MAP`
conservador protege), `viewer@example.com` (read-only).

```bash
# 1. (opcional) popular o banco local com o cenário
pnpm db:reset:local

# 2. Ligar o mock. IMPORTANTE: o @cloudflare/vite-plugin lê o .dev.vars ao lado do
# wrangler.json (config/.dev.vars), NÃO o da raiz. Ponha a flag lá:
echo 'ETUS_GATEWAY_MOCK=1' >> config/.dev.vars # (config/.dev.vars é gitignored)

# 3. Subir e logar como um usuário do cenário, depois abrir /workspaces
pnpm dev
# → POST /auth/test-login {"email":"multi@example.com"} → abra http://localhost:8787/workspaces
```

> O mock é **só para dev/teste**. Em produção (`ETUS_GATEWAY_AUTHORITY=true`) o
> `/api/me` resolve do gateway real; o mock é ignorado. Cobertura: unit
> (`tests/unit/server/dev/`), integração (`tests/integration/api/me.test.ts` dirige o
> mock pela wiring real via `buildApp`) e E2E (`tests/e2e/workspaces.spec.ts`).

---

### 6. Banco de Dados com Hash Diferente
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"sync:template:check": "./scripts/sync-template.sh && git diff --exit-code packages/bhono-app/templates/"
},
"dependencies": {
"@etus/auth": "^0.8.0",
"@etus/auth": "^0.9.1",
"@etus/seven-react": "0.1.0-beta.3",
"@etus/tokens": "0.4.0-beta.2",
"@etus/ui": "0.4.0-beta.2",
Expand Down
10 changes: 3 additions & 7 deletions packages/bhono-app/templates/base/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,9 @@
"includeCoAuthoredBy": false,
"permissions": {
"allow": [
"Edit:*",
"Write:*",
"MultiEdit:*",
"NotebookEdit:*",
"Bash:*",
"WebSearch:*",
"WebFetch:*",
"Edit",
"Write",
"NotebookEdit",
"Bash(grep:*)",
"Bash(diff:*)",
"Bash(echo:*)",
Expand Down
5 changes: 5 additions & 0 deletions packages/bhono-app/templates/base/.npmrc
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
@etus:registry=https://registry.npmjs.org/
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
# Resolve internal workspace deps (e.g. create-bhono -> @etus/bhono) to the local
# package instead of the registry. Without this, a release bump points the dep at
# a version that isn't published yet, so `pnpm install` can't resolve it and every
# release stalls on the lockfile (ERR_PNPM_NO_MATCHING_VERSION / OUTDATED_LOCKFILE).
link-workspace-packages=true
1 change: 1 addition & 0 deletions packages/bhono-app/templates/base/_gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dist/
.wrangler/
.dev.vars
docs/openapi.json
seed.sql

# Test outputs
.test-output/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Local Worker variables used by `pnpm dev`.
# Local Worker variables used by `pnpm dev`. Copy this to `config/.dev.vars`.
#
# This file MUST live next to the Wrangler config (config/wrangler.json): wrangler
# and @cloudflare/vite-plugin only read `.dev.vars` from the config file's directory,
# so a `.dev.vars` at the repo root is ignored by `pnpm dev`.

ENVIRONMENT=development
APP_URL=http://localhost:8787
Expand All @@ -19,6 +23,12 @@ ETUS_ADMIN_EMAILS=admin@yourdomain.com
# ETUS_RESOURCE_ID=yourapp.example.com
# ETUS_INTEGRATION_KEY=ag_app_yourapp_xxx.yyy

# DEV-ONLY gateway mock — serve the local multi-tenant scenario fixture
# (src/server/dev/gateway-scenario.ts) from /api/me so you can click through the
# Workspaces UI and per-account gateway roles without a live gateway. Log in via
# /auth/test-login as a scenario user (e.g. multi@example.com). Ignored in production.
# ETUS_GATEWAY_MOCK=1

# SendGrid
SENDGRID_API_KEY=your-sendgrid-api-key
SENDGRID_FROM_EMAIL=noreply@yourdomain.com
Expand Down
10 changes: 9 additions & 1 deletion packages/bhono-app/templates/base/config/wrangler.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,15 @@
"binding": "ASSETS",
"run_worker_first": [
"/api/*",
"/auth/*"
"/auth/*",
"/accounts",
"/accounts/*",
"/audit",
"/audit/*",
"/invitations",
"/invitations/*",
"/health",
"/health/*"
]
},
"vars": {
Expand Down
84 changes: 84 additions & 0 deletions packages/bhono-app/templates/base/docs/SETUP-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,90 @@ bloqueia; uma indisponibilidade transitória serve o cache (não derruba o app).
> (v0.6.0+ usa D1 via `createSqlSessionStore`); pode ser removido se nenhum outro
> código depender dele.

#### Papéis por-conta do gateway (@etus/auth v0.9.1)

Além dos **scopes** (`SCOPE_MAP`), o gateway resolve um **papel por conta** do
usuário (modelo Auth0 Organizations: `viewer < editor < manager < admin`, da
migration 0070 do gateway). Este boilerplate **lê** esses papéis para autorização
(camada org-level, ao lado dos workspaces locais) — a gestão de membros continua
**local** (modelo híbrido).

- **`ACCOUNT_ROLE_MAP`** (`src/server/auth/matrix.ts`) — paralelo ao `SCOPE_MAP`,
mapeia cada papel-por-conta do gateway → permissões locais (entradas do
`PERMISSION_CATALOG`). Ligado via `gatewayAuthority.accountRoleMap` em
`setup.ts`. O `@etus/auth` **une** essas permissões entre **todas** as contas do
usuário (super-admin conta como `admin` em todas) e injeta em `authPermissions`.
É um grant **coarse, org-level** — ajuste por produto.
- **Gating preciso por-conta** — para "manager nesta conta específica", use o guard
do pacote `auth.requireGatewayAccountRole(slug, role)` (não o mapa global). Um
super-admin sempre passa; lança `NotAccountMemberError`/`AccountRoleRequiredError`.
- **Contexto do usuário** — `GET /api/me` (`src/server/routes/me/index.ts`) devolve
`{ accounts: [{id,slug,name,role}], superAdmin }` resolvido pelo gateway. Vazio /
`false` quando `ETUS_GATEWAY_AUTHORITY` está off (shape sempre seguro).
- **No client** — o hook `useGatewayAccounts()`
(`src/client/hooks/use-gateway-accounts.ts`) lê o `/api/me` e expõe `accounts`,
`superAdmin` e `hasAccountRole(slug, role)` para gatear a UI:

```tsx
const { accounts, superAdmin, hasAccountRole } = useGatewayAccounts()

// badge de papel por conta do gateway:
{accounts.map((a) => <Badge key={a.id}>{a.name}: {a.role}</Badge>)}

// gating de UI — NÃO é fronteira de segurança (o guard do server é a autoridade):
{hasAccountRole('unum', 'manager') && <InviteButton accountSlug="unum" />}
```

> ⚠️ **Regras de segurança do `ACCOUNT_ROLE_MAP`** (é grant **org-level**: o pacote
> une as permissões entre **todas** as contas do usuário, sem escopo por-conta):
> - **Nunca** use `'*'` ou wildcard de namespace (`resources:*`) — um usuário que
> seja `admin` em **qualquer** conta (até uma não-relacionada) passaria esse guard
> no app inteiro. Mantenha valores **bounded e não-destrutivos** (sem `:delete`,
> `billing:manage`). Há um teste que falha se um wildcard entrar.
> - Para autz por conta/workspace **específico** (ex.: "admin DESTA conta pode
> deletá-la"), use `auth.requireGatewayAccountRole(slug, role)` no server e
> `hasAccountRole(slug, role)` no client — **não** este mapa.
> - As permissões aqui são **unidas** com as do papel local (aditivas): remover um
> usuário de uma conta **local** NÃO revoga o que o gateway concede. O gateway é a
> autoridade do que ele resolve; com gateway-authority ligado, a gestão de membros
> local não é um kill-switch de autorização.

#### Validar a UI multi-tenant localmente (gateway mock)

Os papéis por-conta do gateway vêm do gateway via HTTP — local não há gateway. Para
**validar a UI** (e escrever testes) sem um gateway ao vivo, há um **mock de dev**:

- **`src/server/dev/gateway-scenario.ts`** — cenário multi-tenant fixo, por e-mail
(alinhado ao `seed.ts`). O `/api/me`, quando o mock está ligado, resolve as contas
do gateway do usuário logado a partir desse fixture. Gated **duas vezes**:
`ENVIRONMENT !== 'production'` **e** `ETUS_GATEWAY_MOCK` truthy — nunca em produção.
- **Página `Workspaces`** (`src/client/routes/_authenticated/workspaces.tsx`, no nav)
— renderiza `useGatewayAccounts()`: banner de super-admin, um card por conta com o
badge de papel (viewer/editor/manager/admin) e o que ele concede; empty state.

Usuários do cenário (logue via `/auth/test-login`): `superadmin@example.com`
(super-admin), `admin@example.com` (admin em Acme), `multi@example.com` (**admin em
Initech + viewer em Acme** — o caso de over-grant cross-account que o `ACCOUNT_ROLE_MAP`
conservador protege), `viewer@example.com` (read-only).

```bash
# 1. (opcional) popular o banco local com o cenário
pnpm db:reset:local

# 2. Ligar o mock. IMPORTANTE: o @cloudflare/vite-plugin lê o .dev.vars ao lado do
# wrangler.json (config/.dev.vars), NÃO o da raiz. Ponha a flag lá:
echo 'ETUS_GATEWAY_MOCK=1' >> config/.dev.vars # (config/.dev.vars é gitignored)

# 3. Subir e logar como um usuário do cenário, depois abrir /workspaces
pnpm dev
# → POST /auth/test-login {"email":"multi@example.com"} → abra http://localhost:8787/workspaces
```

> O mock é **só para dev/teste**. Em produção (`ETUS_GATEWAY_AUTHORITY=true`) o
> `/api/me` resolve do gateway real; o mock é ignorado. Cobertura: unit
> (`tests/unit/server/dev/`), integração (`tests/integration/api/me.test.ts` dirige o
> mock pela wiring real via `buildApp`) e E2E (`tests/e2e/workspaces.spec.ts`).

---

### 6. Banco de Dados com Hash Diferente
Expand Down
2 changes: 1 addition & 1 deletion packages/bhono-app/templates/base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"sync:template:check": "./scripts/sync-template.sh && git diff --exit-code packages/bhono-app/templates/"
},
"dependencies": {
"@etus/auth": "^0.8.0",
"@etus/auth": "^0.9.1",
"@etus/seven-react": "0.1.0-beta.3",
"@etus/tokens": "0.4.0-beta.2",
"@etus/ui": "0.4.0-beta.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface SidebarProps {
const mainNavItems = [
{ to: '/dashboard', label: 'Dashboard', icon: Icons.dashboard },
{ to: '/team', label: 'Team', icon: Icons.users },
{ to: '/workspaces', label: 'Workspaces', icon: Icons.layers },
{ to: '/integrations', label: 'Integrations', icon: Icons.blocks },
]

Expand Down
Loading