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
48 changes: 47 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ Google SSO y SAML son configuracion, no estrategias de autenticacion implementad

- SvelteKit 2, Svelte 5, Vite 8 y TypeScript 6 strict
- Tailwind CSS 4 y `@lucide/svelte`
- Internacionalizacion con `svelte-i18n` (español e ingles)
- Bun como package manager
- GitDB como unica capa de persistencia
- Vitest, ESLint y Prettier
- Playwright para tests e2e de RBAC (`e2e/`)

Comandos:

Expand All @@ -24,16 +26,31 @@ bun run build
bun run check
bun run lint
bun run test
bun run test:e2e
bun run test:e2e:ui
bun run format:check
```

Los tests usan Vitest con `bun run test`. No usar `bun test`: el runner nativo de Bun no carga
los aliases ni plugins de Vite/SvelteKit del proyecto.

Aliases de importación (`tsconfig.json`): Usar `$modules` para módulos de lógica de negocio,
`$lib` para componentes y utilidades compartidas, y rutas relativas para imports locales.
Ej: `import { userService } from '$modules/auth'` (no `../../modules/auth`).

`bun run test:e2e` ejecuta la suite RBAC de Playwright bajo `e2e/` (requiere
`bunx playwright install --with-deps chromium` una sola vez). `e2e/global-setup.ts` crea su
propio repositorio local GitDB descartable, siembra todas las personas de la matriz de permisos
e inicia el servidor dev contra él—nunca toca el repositorio configurado en `.env`. Las cookies
de sesión se emiten directamente (mismo esquema HMAC que `SessionService`), sin necesidad de
automatizar el login excepto en `e2e/specs/login.spec.ts`.

## Arquitectura

La logica vive en `src/modules/<module>/` con `domain/`, `application/`, `infrastructure/` e
`index.ts` como composition root. Los modulos actuales son `auth`, `config`, `organization`,
`index.ts` como composition root. El `domain/` contiene entidades (`*.domain.ts`) y datos
centralizados (`*.data.ts`) para constantes: permisos de roles, defaults de proyecto, pesos de
riesgo y mapeos de herramientas. Los modulos actuales son `auth`, `config`, `organization`,
`projects`, `storage` y `code-report`. Las rutas deben importar desde el `index.ts` publico.

GitDB es la unica fuente de verdad para usuarios, roles, API keys, organizaciones, proyectos,
Expand All @@ -47,6 +64,35 @@ Los permisos usan `section:action` con scope global, de organizacion o de proyec
`can()` o `isAdmin()` desde `$modules/auth`; `locals.user.role` es un objeto, no el string
`admin`.

**Gating de permisos en UI:** En loaders de rutas, usar `cancanService.canSessionUser()` para verificar
permisos específicos y pasarlos a componentes como props (`canCreate`, `canUpdate`, `canDelete`) para
ocultar acciones que el usuario no puede realizar. El layout raíz (`+layout.server.ts`) calcula permisos
granulares de lectura para settings de org (proyectos, usuarios, roles, configuración global, backups, servidor-keys, audit)
y módulos de proyecto (vault, codereport, stateiac) combinándolos con lógica OR (`canReadProjectVault = canReadProjectVaultSecrets || canReadProjectVaultEnvironments`)
para obtener flags de lectura de alto nivel que se filtran en AppSidebar según acceso específico.

Roles por defecto en `src/modules/auth/domain/role-permissions.data.ts`. Los permisos incluyen scope
como prefijo (ej: `organization:projects:read`, `project:vault:secrets:all`) y se almacenan verbatim sin
transformaciones. Recursos de módulos tienen sub-permisos granulares (ej: `project:vault:secrets:read`, `project:vault:environments:read`):
- **Cluster Admin**: vault, openreport, stateiac (todos)
- **Cluster User**: sin permisos propios; rol base para acceso a nivel cluster
- **Organization Admin**: todos los permisos de org (proyectos, usuarios, roles, configuración global, backups, server-access-keys, audit)
- **Organization Developer**: solo read/create/update de proyectos
- **Project Admin**: metadata/usuarios/roles/server-keys/audit plus todos los permisos de módulos (vault, codereport, stateiac)
- **Project Developer/Viewer**: acceso granular a módulos con permisos diferenciados (solo lectura en Viewer)

Los permisos de organización se propagan a sus proyectos solo cuando no hay un assignment explícito a nivel de
proyecto. Un usuario con `organization:projects:read` puede satisfacer `project:project:read` en cualquier
proyecto—pero si tiene un rol específico del proyecto, ese assignment es autoritario y los permisos de org no
aplican (regla most-specific-wins). Permite delegación de autoridad granular con restricciones por-proyecto.

`canManageOrganization()` controla acceso al area `/settings`, mientras que `canViewOrganization()` incluye
tambien usuarios con acceso solo a proyectos bajo la organización (ven el overview pero no pueden acciones de org-scope).

Al crear una organizacion (via bootstrap o cluster-settings), se llama automaticamente a
`roleService.createDefaultOrganizationRoles()`. Al crear un proyecto, se llama a
`roleService.createDefaultProjectRoles()`. Ambas operaciones inicializan sus roles por defecto.

- No guardar `.env`, credenciales Git, API keys ni secretos en el repositorio.
- Usar una `GITDB_ENCRYPTION_KEY` larga y aleatoria en produccion.
- Mantener scrypt para passwords, HMAC para sesiones y comparaciones timing-safe.
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,24 @@ jobs:

- name: Run unit tests
run: bun run test

e2e-tests:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Install Playwright browsers
run: bunx playwright install --with-deps chromium

- name: Run RBAC e2e tests
run: bun run test:e2e
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ data/
.env
.claude/.claude-md-review-state
code-report-analysis/
.gitdb*/
.gitdb*/
e2e/.tmp/
test-results/
playwright-report/
blob-report/
68 changes: 67 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ The roadmap is in `IDEAS.md`. Do not describe roadmap items as implemented featu

- SvelteKit 2, Svelte 5, Vite 8, TypeScript 6 strict mode
- Tailwind CSS 4 and `@lucide/svelte`
- Internationalization via `svelte-i18n` (Spanish and English)
- Bun package manager; do not use npm or yarn
- GitDB (`@getgitops/gitdb`) as the only persistence layer
- Vitest, ESLint, and Prettier
- Playwright for RBAC end-to-end tests (`e2e/`)

```bash
bun install
Expand All @@ -26,23 +28,41 @@ bun run build
bun run check
bun run lint
bun run test
bun run test:e2e
bun run test:e2e:ui
bun run format:check
```

Tests use Vitest through `bun run test`. Do not use the native `bun test` runner: it does not load
the Vite/SvelteKit aliases and plugins used by this project.

Import aliases (`tsconfig.json`): Use `$modules` for business logic modules, `$lib` for shared
components and utilities, and relative paths for route-local imports. Examples:
`import { userService } from '$modules/auth'` (not `../../modules/auth`),
`import Button from '$lib/components/Button.svelte'`.

`bun run test:e2e` runs the Playwright RBAC suite under `e2e/` (requires
`bunx playwright install --with-deps chromium` once). `e2e/global-setup.ts` creates its own
throwaway local GitDB repository, seeds every persona the permission matrix needs, and starts the
dev server against it — it never touches the repository configured in `.env`. Session cookies are
minted directly (same HMAC scheme as `SessionService`), so specs don't need to drive the login
form except in `e2e/specs/login.spec.ts`, which covers that mechanism itself.

## Architecture

Business logic lives in `src/modules/<module>/`:

```text
domain/ entities and business rules
domain/ entities, business rules, and data constants
application/ use cases and services
infrastructure/ repositories and adapters
index.ts public API and composition root
```

Domain layers include entity classes (e.g., `*.domain.ts`) and centralized data files (e.g.,
`*.data.ts`) for configuration constants: role permissions, default project settings, risk weights,
and tool policy mappings.

Current modules include `auth`, `config`, `organization`, `projects`, `storage`, and
`code-report`. Shared infrastructure lives in `src/lib/`. Routes should import module APIs from
their `index.ts`, not internal layers.
Expand Down Expand Up @@ -77,6 +97,52 @@ if (!allowed) {
}
```

**UI permission gating:** In route loaders, use `cancanService.canSessionUser()` to check specific
action permissions and pass them to components as props (`canCreate`, `canUpdate`, `canDelete`) to conditionally
render actions in the UI. This prevents users from seeing actions they cannot perform:

```typescript
const canCreate = await cancanService.canSessionUser(locals.user, 'project:roles:create', {
scope: 'project',
projectId: project.id,
organizationId: project.organization?.id,
});
return { roles, canCreate };
```

The root layout (`+layout.server.ts`) calculates granular read permissions for each settings section and
passes them to the AppSidebar component, which filters sidebar items based on specific resource permissions:
- **Project level:** `project:project:read`, `project:users:read`, `project:roles:read`, `project:server-keys:read`, `project:audit:read`, plus module-specific permissions: `project:vault:secrets:read`, `project:vault:environments:read`, `project:codereport:reports:read`, `project:codereport:dependencies:read`, `project:codereport:vulnerabilities:read`, `project:stateiac:stacks:read`, `project:stateiac:states:read`, `project:stateiac:history:read`. High-level read flags combine granular permissions with OR logic (e.g., `canReadProjectVault = canReadProjectVaultSecrets || canReadProjectVaultEnvironments`)
- **Organization level:** `organization:projects:read`, `organization:users:read`, `organization:roles:read`, `organization:settings:read`, `organization:backups:read`, `organization:server-keys:read`, `organization:audit:read`

This ensures the UI only displays navigation items for sections the user has permission to view.

Default roles and permissions are centralized in `src/modules/auth/domain/role-permissions.data.ts`.
Permissions always include their scope as a prefix (e.g., `organization:projects:read`,
`project:vault:secrets:all`) and are stored verbatim—there is no scope-stripping transformation:
- **Cluster Admin** (`vault:all`, `openreport:all`, `stateiac:all`)
- **Cluster User** (no inherent permissions; used as base role for cluster-level access)
- **Organization Admin** (all org-level permissions: projects, users, roles, settings, backups, server-keys, audit)
- **Organization Developer** (read/create/update projects only)
- **Project Admin** (all project-level permissions: project metadata, users, roles, server-keys, audit; plus all module permissions: vault secrets/environments, codereport reports/dependencies/vulnerabilities, stateiac stacks/states/history)
- **Project Developer** (read/create/update project resources; no deletion or admin; granular module access)
- **Project Viewer** (read-only: project metadata, all modules, vault secrets/environments, codereport reports/dependencies/vulnerabilities, stateiac stacks/states/history)

Module resources have granular sub-permissions: `project:vault:secrets:read`, `project:vault:environments:read`, `project:codereport:reports:read`, `project:codereport:dependencies:read`, `project:codereport:vulnerabilities:read`, `project:stateiac:stacks:read`, `project:stateiac:states:read`, `project:stateiac:history:read`. These allow fine-grained access control within each module.

Organization-level permissions cascade into their projects only when no explicit project-level assignment exists
for that user. A user with `organization:projects:read` can satisfy a `project:project:read` check on any project
in that organization—but if they have a project-specific role assignment, that assignment is authoritative and
organization permissions do not apply (most-specific-wins rule). This allows coarse-grained org roles to delegate
authority downward, while still permitting per-project restrictions.

Two helpers distinguish organization visibility from management: `canManageOrganization()` gates the `/settings` area,
while `canViewOrganization()` also includes users whose only access is to a project under that organization
(they see the org overview, but cannot perform org-scope actions).

When creating an organization (via bootstrap or cluster settings), `roleService.createDefaultOrganizationRoles()` is
automatically invoked. When creating a project, `roleService.createDefaultProjectRoles()` is automatically invoked. Both
operations initialize their respective default roles. Keep authorization tests beside changes to permission behavior.
Machine-to-machine requests authenticate with `Authorization: Bearer gvs_...`; `hooks.server.ts`
resolves them into `locals.apiKey` and `cancanService.canApiKey()` confines a project key to its
own project.
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ Tests use Vitest through `bun run test`. The native `bun test` runner does not l

Add or update focused tests for changed behavior. Authorization, persistence, API handlers, and security-sensitive changes should always include tests where practical.

RBAC changes should also be covered in the Playwright suite under `e2e/` (`bun run test:e2e`,
`bunx playwright install --with-deps chromium` once beforehand). It runs against a throwaway,
self-seeded GitDB instance — see the "End-to-end tests" section in README.md.

## Pull requests

- Explain the problem and the approach.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,19 @@ bun run test
bun run format:check
```

### End-to-end tests

RBAC (roles/permissions across every catalog resource) is covered by a Playwright suite under
`e2e/`. It's fully self-contained: `e2e/global-setup.ts` creates a throwaway local GitDB
repository, seeds it directly with every persona the matrix needs, and starts the dev server
against it — it never touches the repository configured in your own `.env`.

```bash
bunx playwright install --with-deps chromium # once
bun run test:e2e
bun run test:e2e:ui # interactive UI mode
```

## Contributing

Forks and first-time contributors are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) for the
Expand Down
9 changes: 9 additions & 0 deletions bun.lock

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

42 changes: 42 additions & 0 deletions e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { test as base } from '@playwright/test';
import crypto from 'node:crypto';
import { readFileSync } from 'node:fs';
import { E2E_BASE_URL, E2E_ENCRYPTION_KEY, SEED_OUTPUT_PATH } from '../global-setup';
import type { PersonaKey, SeedOutput } from './seed';

let cachedSeed: SeedOutput | null = null;

export function getSeed(): SeedOutput {
if (!cachedSeed) {
cachedSeed = JSON.parse(readFileSync(SEED_OUTPUT_PATH, 'utf-8')) as SeedOutput;
}
return cachedSeed;
}

export function sessionTokenFor(userId: string): string {
const signature = crypto.createHmac('sha256', E2E_ENCRYPTION_KEY).update(userId).digest('hex');
return `${userId}.${signature}`;
}

type Fixtures = {
loginAs: (personaKey: PersonaKey) => Promise<void>;
};

export const test = base.extend<Fixtures>({
loginAs: async ({ context }, use) => {
await use(async (personaKey: PersonaKey) => {
const seed = getSeed();
const persona = seed.personas[personaKey];
if (!persona) throw new Error(`Unknown persona: ${personaKey}`);
await context.addCookies([
{
name: 'pos_session',
value: sessionTokenFor(persona.userId),
url: E2E_BASE_URL,
},
]);
});
},
});

export { expect } from '@playwright/test';
26 changes: 26 additions & 0 deletions e2e/fixtures/expect-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Page, Response } from '@playwright/test';
import { expect } from '@playwright/test';

async function gotoWithResponse(page: Page, path: string): Promise<Response | null> {
const response = await page.goto(path);
if (response) return response;
return page.goto(path);
}

export async function expectDenied(page: Page, path: string) {
const response = await gotoWithResponse(page, path);
const requestedPath = new URL(path, page.url()).pathname;
const finalPath = new URL(page.url()).pathname;
const status = response?.status() ?? 0;
expect(finalPath !== requestedPath || status === 403, `expected ${path} to be denied`).toBe(
true,
);
}

export async function expectAllowed(page: Page, path: string) {
const response = await gotoWithResponse(page, path);
const requestedPath = new URL(path, page.url()).pathname;
const finalPath = new URL(page.url()).pathname;
expect(finalPath, `expected ${path} not to redirect away`).toBe(requestedPath);
expect(response?.status(), `expected ${path} to load successfully`).toBeLessThan(400);
}
Loading
Loading