diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 43ce44d..a36ddf2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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: @@ -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//` 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, @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44a96d9..f9654e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index bae85c5..01d6ffa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,8 @@ data/ .env .claude/.claude-md-review-state code-report-analysis/ -.gitdb*/ \ No newline at end of file +.gitdb*/ +e2e/.tmp/ +test-results/ +playwright-report/ +blob-report/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 0224239..0dbc92f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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//`: ```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. @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 353034f..3605ea7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index e7306e7..ee7c908 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index 07faa29..b86069a 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-node": "^5.2.11", "@sveltejs/kit": "^2.70.3", @@ -205,6 +206,8 @@ "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], @@ -763,6 +766,10 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], @@ -939,6 +946,8 @@ "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts new file mode 100644 index 0000000..a902add --- /dev/null +++ b/e2e/fixtures/auth.ts @@ -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; +}; + +export const test = base.extend({ + 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'; diff --git a/e2e/fixtures/expect-access.ts b/e2e/fixtures/expect-access.ts new file mode 100644 index 0000000..2587692 --- /dev/null +++ b/e2e/fixtures/expect-access.ts @@ -0,0 +1,26 @@ +import type { Page, Response } from '@playwright/test'; +import { expect } from '@playwright/test'; + +async function gotoWithResponse(page: Page, path: string): Promise { + 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); +} diff --git a/e2e/fixtures/seed.ts b/e2e/fixtures/seed.ts new file mode 100644 index 0000000..2593223 --- /dev/null +++ b/e2e/fixtures/seed.ts @@ -0,0 +1,392 @@ +import { apiKeysService, roleService, userAccessService } from '$modules/auth'; +import { organizationService } from '$modules/organization'; +import { projectService } from '$modules/projects'; +import { codeReportService } from '$modules/code-report'; + +export const E2E_PASSWORD = 'E2ePassword!123'; + +export type PersonaKey = + | 'clusterAdmin' + | 'clusterUserNoAccess' + | 'orgAdmin' + | 'orgDeveloper' + | 'orgOutsider' + | 'orgNone' + | 'orgProjectsReadOnly' + | 'projectAdmin' + | 'projectDeveloper' + | 'projectViewer' + | 'projectReadOnly' + | 'projectServerKeysNoDelete' + | 'projectServerKeysNoUpdate' + | 'projectUsersNoUpdate' + | 'vaultSecretsOnly' + | 'vaultEnvironmentsOnly' + | 'stateiacStacksOnly' + | 'projectOutsider'; + +export type PersonaSeed = { + userId: string; + username: string; +}; + +export type SeedOutput = { + personas: Record; + primaryOrgSlug: string; + primaryOrgId: string; + primaryProjectSlug: string; + outsiderOrgSlug: string; + outsiderProjectSlug: string; + seededServiceSlug: string; + apiKeys: { + readOnly: string; + write: string; + outsiderProject: string; + }; +}; + +const CLUSTER_ADMIN_PERMISSIONS = [ + 'cluster:organization:all', + 'cluster:projects:all', + 'cluster:users:all', + 'cluster:settings:all', +]; + +function roleIdBySlug(roles: { id: string; slug: string }[], slug: string): string { + const role = roles.find((r) => r.slug === slug); + if (!role) throw new Error(`Seed role not found: ${slug}`); + return role.id; +} + +export async function seedAll(): Promise { + const clusterAdminRole = await roleService.createRole({ + name: 'Cluster Admin', + slug: 'cluster-admin', + scope: 'cluster', + permissions: CLUSTER_ADMIN_PERMISSIONS, + }); + const clusterUserRole = await roleService.createRole({ + name: 'Cluster User', + slug: 'cluster-user', + scope: 'cluster', + permissions: [], + }); + + const clusterAdmin = await userAccessService.createClusterUser({ + username: 'e2e-cluster-admin', + password: E2E_PASSWORD, + roleId: clusterAdminRole.id, + email: 'cluster-admin@e2e.test', + }); + + const clusterUserNoAccess = await userAccessService.createClusterUser({ + username: 'e2e-cluster-user-none', + password: E2E_PASSWORD, + roleId: clusterUserRole.id, + email: 'cluster-user-none@e2e.test', + }); + + async function bareUser(username: string) { + return userAccessService.createClusterUser({ + username, + password: E2E_PASSWORD, + roleId: clusterUserRole.id, + email: `${username}@e2e.test`, + }); + } + + async function assignProjectRole(projectId: string, userId: string, roleId: string) { + await userAccessService.assignProjectUser({ projectId, userId, roleId }); + } + + // --- primary organization ------------------------------------------------------------- + const primaryOrg = await organizationService.createOrganization({ + name: 'E2E Primary Org', + slug: 'e2e-primary', + }); + await roleService.createDefaultOrganizationRoles(primaryOrg.id); + const primaryOrgRoles = await roleService.listRoles('organization', primaryOrg.id); + const orgAdminRoleId = roleIdBySlug(primaryOrgRoles, 'org-admin'); + const orgDeveloperRoleId = roleIdBySlug(primaryOrgRoles, 'org-developer'); + + const orgAdmin = await userAccessService.createOrganizationUser({ + organizationId: primaryOrg.id, + username: 'e2e-org-admin', + password: E2E_PASSWORD, + roleId: orgAdminRoleId, + email: 'org-admin@e2e.test', + }); + const orgDeveloper = await userAccessService.createOrganizationUser({ + organizationId: primaryOrg.id, + username: 'e2e-org-developer', + password: E2E_PASSWORD, + roleId: orgDeveloperRoleId, + email: 'org-developer@e2e.test', + }); + + const orgProjectsReadOnlyRole = await roleService.createRole({ + name: 'E2E Org Projects Read Only', + slug: 'e2e-org-projects-read-only', + scope: 'organization', + organizationId: primaryOrg.id, + permissions: ['organization:projects:read'], + }); + const orgProjectsReadOnly = await userAccessService.createOrganizationUser({ + organizationId: primaryOrg.id, + username: 'e2e-org-projects-read-only', + password: E2E_PASSWORD, + roleId: orgProjectsReadOnlyRole.id, + email: 'org-projects-read-only@e2e.test', + }); + + // --- primary project under the primary org ---------------------------------------------- + const primaryProject = await projectService.createProject({ + organizationId: primaryOrg.id, + name: 'E2E Primary Project', + slug: 'e2e-project', + modules: { vault: true, codereport: true, stateiac: true }, + }); + await roleService.createDefaultProjectRoles(primaryProject.id); + const primaryProjectRoles = await roleService.listRoles('project', primaryProject.id); + const projectAdminRoleId = roleIdBySlug(primaryProjectRoles, 'project-admin'); + const projectDeveloperRoleId = roleIdBySlug(primaryProjectRoles, 'project-developer'); + const projectViewerRoleId = roleIdBySlug(primaryProjectRoles, 'project-viewer'); + + const projectAdminUser = await bareUser('e2e-project-admin'); + await assignProjectRole(primaryProject.id, projectAdminUser.userId, projectAdminRoleId); + + const projectDeveloperUser = await bareUser('e2e-project-developer'); + await assignProjectRole(primaryProject.id, projectDeveloperUser.userId, projectDeveloperRoleId); + + const projectViewerUser = await bareUser('e2e-project-viewer'); + await assignProjectRole(primaryProject.id, projectViewerUser.userId, projectViewerRoleId); + + const orgNoneUser = await bareUser('e2e-org-none'); + await assignProjectRole(primaryProject.id, orgNoneUser.userId, projectViewerRoleId); + + const readOnlyRole = await roleService.createRole({ + name: 'E2E Project Read Only', + slug: 'e2e-project-read-only', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read'], + }); + const projectReadOnlyUser = await bareUser('e2e-project-read-only'); + await assignProjectRole(primaryProject.id, projectReadOnlyUser.userId, readOnlyRole.id); + + const serverKeysNoDeleteRole = await roleService.createRole({ + name: 'E2E Server Keys No Delete', + slug: 'e2e-server-keys-no-delete', + scope: 'project', + projectId: primaryProject.id, + permissions: [ + 'project:project:read', + 'project:server-keys:read', + 'project:server-keys:create', + 'project:server-keys:update', + ], + }); + const serverKeysNoDeleteUser = await bareUser('e2e-server-keys-no-delete'); + await assignProjectRole(primaryProject.id, serverKeysNoDeleteUser.userId, serverKeysNoDeleteRole.id); + + const serverKeysNoUpdateRole = await roleService.createRole({ + name: 'E2E Server Keys No Update', + slug: 'e2e-server-keys-no-update', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:server-keys:read', 'project:server-keys:create'], + }); + const serverKeysNoUpdateUser = await bareUser('e2e-server-keys-no-update'); + await assignProjectRole(primaryProject.id, serverKeysNoUpdateUser.userId, serverKeysNoUpdateRole.id); + + await apiKeysService.createProjectApiKey({ + projectId: primaryProject.id, + roleId: projectAdminRoleId, + name: 'e2e-seeded-key-no-delete', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + await apiKeysService.createProjectApiKey({ + projectId: primaryProject.id, + roleId: projectAdminRoleId, + name: 'e2e-seeded-key-no-update', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + + const usersNoUpdateRole = await roleService.createRole({ + name: 'E2E Users No Update', + slug: 'e2e-users-no-update', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:users:read'], + }); + const usersNoUpdateUser = await bareUser('e2e-users-no-update'); + await assignProjectRole(primaryProject.id, usersNoUpdateUser.userId, usersNoUpdateRole.id); + + const vaultSecretsOnlyRole = await roleService.createRole({ + name: 'E2E Vault Secrets Only', + slug: 'e2e-vault-secrets-only', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:vault:secrets:read'], + }); + const vaultSecretsOnlyUser = await bareUser('e2e-vault-secrets-only'); + await assignProjectRole(primaryProject.id, vaultSecretsOnlyUser.userId, vaultSecretsOnlyRole.id); + + const vaultEnvironmentsOnlyRole = await roleService.createRole({ + name: 'E2E Vault Environments Only', + slug: 'e2e-vault-environments-only', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:vault:environments:read'], + }); + const vaultEnvironmentsOnlyUser = await bareUser('e2e-vault-environments-only'); + await assignProjectRole( + primaryProject.id, + vaultEnvironmentsOnlyUser.userId, + vaultEnvironmentsOnlyRole.id, + ); + + const stateiacStacksOnlyRole = await roleService.createRole({ + name: 'E2E StateIac Stacks Only', + slug: 'e2e-stateiac-stacks-only', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:stateiac:stacks:read'], + }); + const stateiacStacksOnlyUser = await bareUser('e2e-stateiac-stacks-only'); + await assignProjectRole( + primaryProject.id, + stateiacStacksOnlyUser.userId, + stateiacStacksOnlyRole.id, + ); + + const seededService = await codeReportService.createService({ + project: primaryProject.slug, + name: 'E2E Seeded Service', + }); + + const { token: readOnlyApiKeyToken } = await apiKeysService.createProjectApiKey({ + projectId: primaryProject.id, + roleId: projectViewerRoleId, + name: 'e2e-api-key-read-only', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + const { token: writeApiKeyToken } = await apiKeysService.createProjectApiKey({ + projectId: primaryProject.id, + roleId: projectDeveloperRoleId, + name: 'e2e-api-key-write', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + + const outsiderOrg = await organizationService.createOrganization({ + name: 'E2E Outsider Org', + slug: 'e2e-outsider', + }); + await roleService.createDefaultOrganizationRoles(outsiderOrg.id); + const outsiderOrgRoles = await roleService.listRoles('organization', outsiderOrg.id); + const outsiderOrgAdminRoleId = roleIdBySlug(outsiderOrgRoles, 'org-admin'); + + const orgOutsider = await userAccessService.createOrganizationUser({ + organizationId: outsiderOrg.id, + username: 'e2e-org-outsider', + password: E2E_PASSWORD, + roleId: outsiderOrgAdminRoleId, + email: 'org-outsider@e2e.test', + }); + + const outsiderProject = await projectService.createProject({ + organizationId: outsiderOrg.id, + name: 'E2E Outsider Project', + slug: 'e2e-outsider-project', + modules: { vault: true, codereport: true, stateiac: true }, + }); + await roleService.createDefaultProjectRoles(outsiderProject.id); + const outsiderProjectRoles = await roleService.listRoles('project', outsiderProject.id); + const outsiderProjectAdminRoleId = roleIdBySlug(outsiderProjectRoles, 'project-admin'); + + const projectOutsiderUser = await bareUser('e2e-project-outsider'); + await assignProjectRole( + outsiderProject.id, + projectOutsiderUser.userId, + outsiderProjectAdminRoleId, + ); + + const { token: outsiderApiKeyToken } = await apiKeysService.createProjectApiKey({ + projectId: outsiderProject.id, + roleId: outsiderProjectAdminRoleId, + name: 'e2e-api-key-outsider', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + + return { + personas: { + clusterAdmin: { userId: clusterAdmin.userId, username: clusterAdmin.username }, + clusterUserNoAccess: { + userId: clusterUserNoAccess.userId, + username: clusterUserNoAccess.username, + }, + orgAdmin: { userId: orgAdmin.userId, username: orgAdmin.username }, + orgDeveloper: { userId: orgDeveloper.userId, username: orgDeveloper.username }, + orgOutsider: { userId: orgOutsider.userId, username: orgOutsider.username }, + orgNone: { userId: orgNoneUser.userId, username: orgNoneUser.username }, + orgProjectsReadOnly: { + userId: orgProjectsReadOnly.userId, + username: orgProjectsReadOnly.username, + }, + projectAdmin: { userId: projectAdminUser.userId, username: projectAdminUser.username }, + projectDeveloper: { + userId: projectDeveloperUser.userId, + username: projectDeveloperUser.username, + }, + projectViewer: { userId: projectViewerUser.userId, username: projectViewerUser.username }, + projectReadOnly: { + userId: projectReadOnlyUser.userId, + username: projectReadOnlyUser.username, + }, + projectServerKeysNoDelete: { + userId: serverKeysNoDeleteUser.userId, + username: serverKeysNoDeleteUser.username, + }, + projectServerKeysNoUpdate: { + userId: serverKeysNoUpdateUser.userId, + username: serverKeysNoUpdateUser.username, + }, + projectUsersNoUpdate: { + userId: usersNoUpdateUser.userId, + username: usersNoUpdateUser.username, + }, + vaultSecretsOnly: { + userId: vaultSecretsOnlyUser.userId, + username: vaultSecretsOnlyUser.username, + }, + vaultEnvironmentsOnly: { + userId: vaultEnvironmentsOnlyUser.userId, + username: vaultEnvironmentsOnlyUser.username, + }, + stateiacStacksOnly: { + userId: stateiacStacksOnlyUser.userId, + username: stateiacStacksOnlyUser.username, + }, + projectOutsider: { + userId: projectOutsiderUser.userId, + username: projectOutsiderUser.username, + }, + }, + primaryOrgSlug: primaryOrg.slug, + primaryOrgId: primaryOrg.id, + primaryProjectSlug: primaryProject.slug, + outsiderOrgSlug: outsiderOrg.slug, + outsiderProjectSlug: outsiderProject.slug, + seededServiceSlug: seededService.slug, + apiKeys: { + readOnly: readOnlyApiKeyToken, + write: writeApiKeyToken, + outsiderProject: outsiderApiKeyToken, + }, + }; +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..85fcdb2 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,123 @@ +// Owns the whole E2E lifecycle so there's no ambiguity around Playwright's webServer-vs- +// globalSetup ordering: creates a throwaway local GitDB "remote", seeds it directly (in this +// process) with every persona the RBAC matrix needs, then spawns the dev server pointed at the +// already-cloned data path and waits for it to be ready. Never touches the real dev GitDB +// (gitops-db-local / dev/liam) configured in the project's own .env. +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const E2E_PORT = 5390; +export const E2E_BASE_URL = `http://localhost:${E2E_PORT}`; +export const E2E_ENCRYPTION_KEY = 'e2e-test-encryption-key-do-not-use-in-prod'; + +const ROOT = path.resolve(__dirname, '..'); +// deliberately OUTSIDE the repo (not e2e/.tmp): a throwaway git repo nested inside this repo's +// working tree is one race away from disaster — if GitDB's own git commands ever run before its +// `.git` finishes initializing, git walks up looking for a repo and silently commits into THIS +// project's history instead (which happened once while building this suite — see git log if +// curious, it was cleaned up with `git reset --soft`). Living under the OS temp dir means that +// walk-up has nothing to find. +const TMP_DIR = path.join(os.tmpdir(), 'gitops-platform-e2e'); +const REMOTE_DIR = path.join(TMP_DIR, 'gitdb-remote'); +const DATA_PATH = path.join(TMP_DIR, 'gitdb-data'); +export const SEED_OUTPUT_PATH = path.join(TMP_DIR, 'seed-output.json'); + +function initThrowawayRemote() { + rmSync(TMP_DIR, { recursive: true, force: true }); + mkdirSync(REMOTE_DIR, { recursive: true }); + + const git = (args: string[]) => execFileSync('git', args, { cwd: REMOTE_DIR, stdio: 'ignore' }); + git(['init', '-b', 'main']); + git(['-c', 'user.email=e2e@gitops.local', '-c', 'user.name=e2e', 'commit', '--allow-empty', '-m', 'init']); +} + +function buildEnv(): Record { + return { + ...process.env, + GITDB_REPOSITORY_URL: REMOTE_DIR, + GITDB_BRANCH: 'main', + GITDB_DATA_PATH: DATA_PATH, + GITDB_ENCRYPTION_KEY: E2E_ENCRYPTION_KEY, + GITDB_AUTHOR_NAME: 'e2e', + GITDB_AUTHOR_EMAIL: 'e2e@gitops.local', + GITDB_SYNC_POLL_SECONDS: '86400', + GITDB_USERNAME: '', + GITDB_TOKEN: '', + GITDB_PASSWORD: '', + PORT: String(E2E_PORT), + }; +} + +async function waitForServer(url: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { redirect: 'manual' }); + // any HTTP response (even a redirect to /bootstrap or /maintenance) means the process + // is up; we only need "port is listening and hooks.server.ts responded". + if (response.status > 0) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + throw new Error(`Dev server did not become ready at ${url}: ${String(lastError)}`); +} + +export default async function globalSetup() { + initThrowawayRemote(); + + const env = buildEnv(); + Object.assign(process.env, env); + + const { seedAll } = await import('./fixtures/seed'); + const seedOutput = await seedAll(); + writeFileSync(SEED_OUTPUT_PATH, JSON.stringify(seedOutput, null, 2)); + + // `bun run build` currently fails in this repo independent of e2e/RBAC work (a Tailwind/ + // postcss resolution error under Vite 8 — pre-existing, out of scope here), so `vite preview` + // isn't usable; run the dev server instead. Dev-mode SSR has a separate, pre-existing + // svelte-i18n race on a truly cold server (locale loader hasn't resolved before the first + // synchronous SSR render) — worked around below with a couple of throwaway warm-up requests + // before tests start, rather than touching app code for an unrelated bug. + const server: ChildProcess = spawn( + 'bun', + ['run', 'dev', '--', '--port', String(E2E_PORT), '--strictPort'], + { + cwd: ROOT, + env, + stdio: 'inherit', + }, + ); + + let serverExited = false; + server.on('exit', () => { + serverExited = true; + }); + + await waitForServer(`${E2E_BASE_URL}/auth/login`, 60_000); + if (serverExited) { + throw new Error('Dev server exited before becoming ready'); + } + + // absorb the cold-start svelte-i18n race (see comment above) so real test navigations don't + // hit it: a couple of real page loads, spaced out, until one actually returns 200. + for (let attempt = 0; attempt < 10; attempt += 1) { + const response = await fetch(`${E2E_BASE_URL}/auth/login`); + console.log(`[e2e warmup] attempt ${attempt} -> ${response.status}`); + if (response.status === 200) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + return async () => { + if (!server.killed) { + server.kill(); + } + }; +} diff --git a/e2e/specs/api-key-auth.spec.ts b/e2e/specs/api-key-auth.spec.ts new file mode 100644 index 0000000..ce59b1b --- /dev/null +++ b/e2e/specs/api-key-auth.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { getSeed } from '../fixtures/auth'; + +const seed = getSeed(); +const scanUrl = '/api/code-report/scan'; + +function startBody(overrides: Record = {}) { + return { + status: 'start', + service: seed.seededServiceSlug, + project: seed.primaryProjectSlug, + tool: 'trivy', + ...overrides, + }; +} + +test('no Authorization header is rejected', async ({ request }) => { + const response = await request.post(scanUrl, { data: startBody() }); + expect(response.status()).toBe(401); +}); + +test('an invalid bearer token is rejected', async ({ request }) => { + const response = await request.post(scanUrl, { + headers: { Authorization: 'Bearer gvs_not_a_real_token' }, + data: startBody(), + }); + expect(response.status()).toBe(401); +}); + +test('a read-only key (no codereport:create) is denied', async ({ request }) => { + const response = await request.post(scanUrl, { + headers: { Authorization: `Bearer ${seed.apiKeys.readOnly}` }, + data: startBody(), + }); + expect(response.status()).toBe(403); +}); + +test('a key with codereport:create can start a scan for its own project', async ({ request }) => { + const response = await request.post(scanUrl, { + headers: { Authorization: `Bearer ${seed.apiKeys.write}` }, + data: startBody(), + }); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.success).toBe(true); +}); + +test('a key scoped to a different project is denied (cross-project isolation)', async ({ + request, +}) => { + // valid, working key — just scoped to the outsider project, not the primary one it's targeting + const response = await request.post(scanUrl, { + headers: { Authorization: `Bearer ${seed.apiKeys.outsiderProject}` }, + data: startBody(), + }); + expect(response.status()).toBe(403); +}); + +test('a project-scoped key cannot be used on a non-API browser route', async ({ request }) => { + const response = await request.get( + `/org/${seed.primaryOrgSlug}/projects/${seed.primaryProjectSlug}/settings/overview`, + { headers: { Authorization: `Bearer ${seed.apiKeys.write}` } }, + ); + expect(response.status()).toBe(401); +}); diff --git a/e2e/specs/cluster-settings.spec.ts b/e2e/specs/cluster-settings.spec.ts new file mode 100644 index 0000000..6f730e7 --- /dev/null +++ b/e2e/specs/cluster-settings.spec.ts @@ -0,0 +1,46 @@ +import { test } from '../fixtures/auth'; +import { expectAllowed, expectDenied } from '../fixtures/expect-access'; + +const clusterRoutes = [ + '/cluster-settings/orgs', + '/cluster-settings/roles-permissions', + '/cluster-settings/access-control', + '/cluster-settings/database', + '/cluster-settings/audit', +]; + +test.describe('cluster settings — cluster admin', () => { + test('sees every cluster settings resource', async ({ page, loginAs }) => { + await loginAs('clusterAdmin'); + for (const route of clusterRoutes) { + await expectAllowed(page, route); + } + }); +}); + +test.describe('cluster settings — org admin (not a cluster admin)', () => { + test('is denied every cluster settings resource', async ({ page, loginAs }) => { + await loginAs('orgAdmin'); + for (const route of clusterRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('cluster settings — project admin (not a cluster admin)', () => { + test('is denied every cluster settings resource', async ({ page, loginAs }) => { + await loginAs('projectAdmin'); + for (const route of clusterRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('cluster settings — bare cluster-user role', () => { + test('is denied every cluster settings resource', async ({ page, loginAs }) => { + await loginAs('clusterUserNoAccess'); + for (const route of clusterRoutes) { + await expectDenied(page, route); + } + }); +}); diff --git a/e2e/specs/code-report.spec.ts b/e2e/specs/code-report.spec.ts new file mode 100644 index 0000000..02eb7c9 --- /dev/null +++ b/e2e/specs/code-report.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from '../fixtures/auth'; +import { expectAllowed, expectDenied } from '../fixtures/expect-access'; +import { getSeed } from '../fixtures/auth'; + +const seed = getSeed(); +const base = `/org/${seed.primaryOrgSlug}/projects/${seed.primaryProjectSlug}/code-report`; + +const readOnlyRoutes = [ + `${base}/dashboard`, + `${base}/history`, + `${base}/security-policy`, + `${base}/services`, + `${base}/services/${seed.seededServiceSlug}`, + `${base}/settings`, + `${base}/settings/tools`, +]; + +test.describe('code report — no codereport:*:read grant at all', () => { + test('a project:project:read-only role is denied every sub-page', async ({ + page, + loginAs, + }) => { + await loginAs('projectReadOnly'); + for (const route of readOnlyRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('code report — project viewer (read, no create/update)', () => { + test('sees every sub-page', async ({ page, loginAs }) => { + await loginAs('projectViewer'); + for (const route of readOnlyRoutes) { + await expectAllowed(page, route); + } + }); + + test('the "add service" button is hidden on the services list', async ({ page, loginAs }) => { + await loginAs('projectViewer'); + await page.goto(`${base}/services`); + await expect(page.getByRole('button', { name: 'Añadir servicio' })).toHaveCount(0); + }); + + test('the delete button is hidden on the service detail page', async ({ page, loginAs }) => { + await loginAs('projectViewer'); + await page.goto(`${base}/services/${seed.seededServiceSlug}`); + await expect(page.getByRole('button', { name: 'Borrar servicio' })).toHaveCount(0); + }); +}); + +test.describe('code report — project developer (has create, not delete)', () => { + test('the "add service" button is visible on the services list', async ({ page, loginAs }) => { + await loginAs('projectDeveloper'); + await page.goto(`${base}/services`); + await expect(page.getByRole('button', { name: 'Añadir servicio' })).toBeVisible(); + }); + + // PROJECT_DEVELOPER_PERMISSIONS grants codereport:reports:create/update but not :delete — + // developer stays read+create/update, only admin gets delete. + test('the delete button is still hidden on the service detail page', async ({ + page, + loginAs, + }) => { + await loginAs('projectDeveloper'); + await page.goto(`${base}/services/${seed.seededServiceSlug}`); + await expect(page.getByRole('button', { name: 'Borrar servicio' })).toHaveCount(0); + }); +}); + +test.describe('code report — project admin (has delete)', () => { + test('the delete button is visible on the service detail page', async ({ page, loginAs }) => { + await loginAs('projectAdmin'); + await page.goto(`${base}/services/${seed.seededServiceSlug}`); + await expect(page.getByRole('button', { name: 'Borrar servicio' })).toBeVisible(); + }); +}); + +test.describe('code report — services create, real mutation', () => { + test('project developer can create a service via POST', async ({ page, loginAs }) => { + await loginAs('projectDeveloper'); + const response = await page.request.post(`${base}/services?/create`, { + form: { name: 'E2E Mutation Service', slug: 'e2e-mutation-service', tags: '' }, + }); + const body = await response.json(); + expect(body.type).not.toBe('failure'); + + await page.goto(`${base}/services`); + await expect(page.getByText('E2E Mutation Service')).toBeVisible(); + }); + + test('project viewer gets a 403 on the same POST', async ({ page, loginAs }) => { + await loginAs('projectViewer'); + const response = await page.request.post(`${base}/services?/create`, { + form: { name: 'Should Not Be Created', slug: 'should-not-be-created-service', tags: '' }, + }); + const body = await response.json(); + expect(body.type).toBe('failure'); + expect(body.status).toBe(403); + }); +}); diff --git a/e2e/specs/login.spec.ts b/e2e/specs/login.spec.ts new file mode 100644 index 0000000..fb114b6 --- /dev/null +++ b/e2e/specs/login.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; +import { E2E_PASSWORD } from '../fixtures/seed'; +import { getSeed } from '../fixtures/auth'; + +async function gotoLogin(page: import('@playwright/test').Page) { + await page.goto('/auth/login'); + await page.waitForLoadState('networkidle'); +} + +test('logs in through the real form and reaches the app shell', async ({ page }) => { + const seed = getSeed(); + await gotoLogin(page); + await page.getByLabel('Correo electrónico').fill('cluster-admin@e2e.test'); + await page.getByLabel('Contraseña').fill(E2E_PASSWORD); + await page.getByRole('button', { name: 'Iniciar sesión' }).click(); + await expect(page).not.toHaveURL(/\/auth\/login/); + expect(seed.primaryOrgSlug).toBe('e2e-primary'); +}); + +test('rejects a wrong password', async ({ page }) => { + await gotoLogin(page); + await page.getByLabel('Correo electrónico').fill('cluster-admin@e2e.test'); + await page.getByLabel('Contraseña').fill('wrong-password'); + await page.getByRole('button', { name: 'Iniciar sesión' }).click(); + await expect(page).toHaveURL(/\/auth\/login/); +}); diff --git a/e2e/specs/organization-settings.spec.ts b/e2e/specs/organization-settings.spec.ts new file mode 100644 index 0000000..adfa2dc --- /dev/null +++ b/e2e/specs/organization-settings.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from '../fixtures/auth'; +import { expectAllowed, expectDenied } from '../fixtures/expect-access'; +import { getSeed } from '../fixtures/auth'; + +const seed = getSeed(); +const org = seed.primaryOrgSlug; +const settingsRoutes = [ + `/org/${org}/settings/projects`, + `/org/${org}/settings/access-control`, + `/org/${org}/settings/roles-permissions`, + `/org/${org}/settings/global`, + `/org/${org}/settings/system-backup`, + `/org/${org}/settings/server-access-keys`, + `/org/${org}/settings/audit`, +]; + +test.describe('organization settings — org admin', () => { + test('sees every organization settings resource', async ({ page, loginAs }) => { + await loginAs('orgAdmin'); + for (const route of settingsRoutes) { + await expectAllowed(page, route); + } + }); +}); + +test.describe('organization settings — org developer', () => { + test('sees only projects, denied everywhere else', async ({ page, loginAs }) => { + await loginAs('orgDeveloper'); + await expectAllowed(page, `/org/${org}/settings/projects`); + for (const route of settingsRoutes.filter((r) => !r.endsWith('/projects'))) { + await expectDenied(page, route); + } + }); +}); + +test.describe('organization settings — no org-scope role (project-only access)', () => { + test('sees the org overview but is denied every settings page', async ({ page, loginAs }) => { + await loginAs('orgNone'); + await expectAllowed(page, `/org/${org}/overview`); + for (const route of settingsRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('organization settings — cross-org isolation', () => { + test('a member of a different org is denied every settings page here', async ({ + page, + loginAs, + }) => { + await loginAs('orgOutsider'); + for (const route of settingsRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('organization settings — no access at all', () => { + test('a bare cluster-user is denied everything', async ({ page, loginAs }) => { + await loginAs('clusterUserNoAccess'); + await expectDenied(page, `/org/${org}/overview`); + for (const route of settingsRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('organization roles — "new role" form requires organization:roles:create', () => { + test('a read-only role is denied, not just missing the Save button', async ({ + page, + loginAs, + }) => { + await loginAs('orgProjectsReadOnly'); + await expectDenied(page, `/org/${org}/settings/roles-permissions/new`); + }); +}); + +test.describe('organization projects — real create mutation', () => { + test('org admin can create a project and see it in the list', async ({ page, loginAs }) => { + await loginAs('orgAdmin'); + const response = await page.request.post(`/org/${org}/settings/projects?/createProject`, { + form: { + organizationId: seed.primaryOrgId, + name: 'E2E Mutation Project', + slug: 'e2e-mutation-project', + status: 'active', + }, + }); + const body = await response.json(); + expect(body.type).not.toBe('failure'); + + await page.goto(`/org/${org}/settings/projects`); + await expect(page.getByText('E2E Mutation Project')).toBeVisible(); + }); + + test('a read-only org role gets a 403 on the same POST', async ({ page, loginAs }) => { + await loginAs('orgProjectsReadOnly'); + const response = await page.request.post(`/org/${org}/settings/projects?/createProject`, { + form: { + organizationId: seed.primaryOrgId, + name: 'Should Not Be Created', + slug: 'should-not-be-created-org', + status: 'active', + }, + }); + const body = await response.json(); + expect(body.type).toBe('failure'); + expect(body.status).toBe(403); + }); +}); diff --git a/e2e/specs/project-settings.spec.ts b/e2e/specs/project-settings.spec.ts new file mode 100644 index 0000000..03d3b4e --- /dev/null +++ b/e2e/specs/project-settings.spec.ts @@ -0,0 +1,184 @@ +import { test, expect } from '../fixtures/auth'; +import { expectAllowed, expectDenied } from '../fixtures/expect-access'; +import { getSeed } from '../fixtures/auth'; + +const seed = getSeed(); +const org = seed.primaryOrgSlug; +const project = seed.primaryProjectSlug; +const base = `/org/${org}/projects/${project}`; + +const settingsRoutes = [ + `${base}/settings/overview`, + `${base}/settings/access-control`, + `${base}/settings/roles-permissions`, + `${base}/settings/server-access-keys`, + `${base}/settings/audit`, +]; + +test('project admin sees every project settings resource', async ({ page, loginAs }) => { + await loginAs('projectAdmin'); + for (const route of settingsRoutes) { + await expectAllowed(page, route); + } +}); + +test('a project outsider (role on a different project) is denied everything here', async ({ + page, + loginAs, +}) => { + await loginAs('projectOutsider'); + for (const route of settingsRoutes) { + await expectDenied(page, route); + } +}); + +test.describe('project overview — read-only role (project:project:read only)', () => { + test('Danger Zone and Save are hidden, fields render as read-only text', async ({ + page, + loginAs, + }) => { + await loginAs('projectReadOnly'); + await page.goto(`${base}/settings/overview`); + await expect(page.getByRole('button', { name: 'Guardar cambios' })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Zona de peligro' })).toHaveCount(0); + // the name field must not be an editable input when read-only + await expect(page.locator('input#edit-project-name')).toHaveCount(0); + }); +}); + +test.describe('project overview — project admin', () => { + test('Danger Zone and Save are visible, name field is editable', async ({ page, loginAs }) => { + await loginAs('projectAdmin'); + await page.goto(`${base}/settings/overview`); + await expect(page.getByRole('button', { name: 'Guardar cambios' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Zona de peligro' })).toBeVisible(); + await expect(page.locator('input#edit-project-name')).toBeVisible(); + }); +}); + +test.describe('server access keys — no delete permission', () => { + test('Rotate is visible, Revoke is not', async ({ page, loginAs }) => { + await loginAs('projectServerKeysNoDelete'); + await page.goto(`${base}/settings/server-access-keys`); + await expect(page.getByRole('button', { name: 'Crear key' })).toBeVisible(); + await expect(page.getByRole('button', { name: /rotar|regenerar/i }).first()).toBeVisible(); + await expect(page.getByRole('button', { name: /revocar|eliminar/i })).toHaveCount(0); + }); +}); + +test.describe('server access keys — no update permission', () => { + test('neither Rotate nor Revoke are visible', async ({ page, loginAs }) => { + await loginAs('projectServerKeysNoUpdate'); + await page.goto(`${base}/settings/server-access-keys`); + await expect(page.getByRole('button', { name: /rotar|regenerar/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /revocar|eliminar/i })).toHaveCount(0); + }); +}); + +test.describe('server access keys — real revoke mutation', () => { + // posted directly (not clicked through the UI): the modal's confirm button depends on + // client-side hydration, which is unrelated to what this test cares about — whether the + // server actually revokes the key for a role with delete permission, not whether the click + // wiring works. + test('a role with delete permission can actually revoke a key', async ({ + page, + loginAs, + }) => { + await loginAs('projectAdmin'); + await page.goto(`${base}/settings/server-access-keys`); + const before = await page.getByRole('button', { name: /revocar/i }).count(); + + const keyId = await page.locator('input[name="keyId"]').first().getAttribute('value'); + expect(keyId, 'expected at least one seeded key on the page').toBeTruthy(); + + const response = await page.request.post(`${base}/settings/server-access-keys?/revoke`, { + form: { keyId: keyId! }, + }); + const body = await response.json(); + expect(body.type).not.toBe('failure'); + + await page.reload(); + await expect(page.getByRole('button', { name: /revocar/i })).toHaveCount(before - 1); + }); +}); + +test.describe('project users/roles — read guard (no project:users:read / project:roles:read)', () => { + test('a project:project:read-only role is denied the user list', async ({ page, loginAs }) => { + await loginAs('projectReadOnly'); + await expectDenied(page, `${base}/settings/access-control`); + }); + + test('a project:project:read-only role is denied the role list and detail', async ({ + page, + loginAs, + }) => { + await loginAs('projectReadOnly'); + await expectDenied(page, `${base}/settings/roles-permissions`); + }); + + test('a project:project:read-only role is denied the "new role" form', async ({ + page, + loginAs, + }) => { + await loginAs('projectReadOnly'); + await expectDenied(page, `${base}/settings/roles-permissions/new`); + }); +}); + +test.describe('project users — role-change control (canUpdate)', () => { + test('a role without project:users:update sees a static label, not the dropdown', async ({ + page, + loginAs, + }) => { + await loginAs('projectUsersNoUpdate'); + await page.goto(`${base}/settings/access-control`); + // the role-change trigger is a + {#if canSave} + + {/if} onAccessLevelChange(permissionRow, event)} > {#each selectableAccessLevels(permissionRow) as level} @@ -591,7 +604,7 @@ togglePermission(permissionRow, permission)} class="h-4 w-4 rounded border-slate-300 accent-[color:var(--primary)] disabled:opacity-50" title={inheritedPermission(permissionRow, action) ?? permission} diff --git a/src/lib/components/RolePermissions.svelte b/src/lib/components/RolePermissions.svelte index b5c87fc..5293dac 100644 --- a/src/lib/components/RolePermissions.svelte +++ b/src/lib/components/RolePermissions.svelte @@ -6,6 +6,7 @@ export let title = ''; export let description = ''; export let baseHref = 'roles-permissions'; + export let canCreate = true; type RoleRow = { id: string; @@ -35,24 +36,30 @@

{resolvedTitle}

{resolvedDescription}

-
- - {$_('rolePermissions.addRole')} - + {#if canCreate} + + + {$_('rolePermissions.addRole')} + + {/if} {#if roles.length === 0} -
+
{$_('rolePermissions.empty')}
{:else}
- + @@ -61,7 +68,10 @@ {#each roles as role (role.id)} {/if} @@ -494,7 +519,9 @@ {#if scope !== 'project'}
- +
- +
- + {:else}
- +
-
{$_('usersComponent.inviteModalTitle')}
+
+ {$_('usersComponent.inviteModalTitle')} +
{#if inviteError} @@ -603,7 +640,9 @@ {$_('usersComponent.inviteModalDescription')}

- +
-
{$_('usersComponent.removeModalTitle')}
+
+ {$_('usersComponent.removeModalTitle')} +

- {$_('usersComponent.removeModalDescriptionStart')} {removeModalUser.username}{$_('usersComponent.removeModalDescriptionEnd')} + {$_('usersComponent.removeModalDescriptionStart')} + {removeModalUser.username}{$_( + 'usersComponent.removeModalDescriptionEnd', + )}

@@ -664,7 +706,8 @@ type="button" on:click={() => (removeModalUser = null)} class="btn-secondary rounded-md px-3 py-2 text-sm font-medium" - >{$_('common.cancel')} + >{$_('common.cancel')}
diff --git a/src/lib/config/permissions.ts b/src/lib/config/permissions.ts index 4501de2..9df2ab5 100644 --- a/src/lib/config/permissions.ts +++ b/src/lib/config/permissions.ts @@ -43,6 +43,10 @@ const permissions = { resource: 'roles', permissions: grantsFor('organization', 'roles'), }, + settings: { + resource: 'settings', + permissions: grantsFor('organization', 'settings'), + }, backups: { resource: 'backups', permissions: grantsFor('organization', 'backups'), diff --git a/src/lib/database/schemas.ts b/src/lib/database/schemas.ts index efb4257..e3e8b45 100644 --- a/src/lib/database/schemas.ts +++ b/src/lib/database/schemas.ts @@ -27,8 +27,8 @@ export const RoleEntity = entity('roles', { slug: text().notNull(), name: text().notNull(), scope: text().notNull().default('cluster'), - organizationId: uuid(), - projectId: uuid(), + organizationId: uuid().$defaultFn(() => null), + projectId: uuid().$defaultFn(() => null), permissions: json() .notNull() .$defaultFn(() => []), @@ -44,8 +44,8 @@ export const RoleEntity = entity('roles', { // `roleId` is only meaningful for project keys and points to a project-scoped role export const ApiKeyEntity = entity('api_keys', { id: uuid().primaryKey(), - userId: uuid(), - projectId: uuid(), + userId: uuid().notNull(), + projectId: uuid().$defaultFn(() => null), roleId: uuid(), createdByUserId: uuid(), name: text().notNull(), @@ -64,8 +64,8 @@ export const UserAccessEntity = entity('user_access', { userId: uuid().notNull(), roleId: uuid().notNull(), scope: text().notNull(), - organizationId: uuid(), - projectId: uuid(), + organizationId: uuid().$defaultFn(() => null), + projectId: uuid().$defaultFn(() => null), status: text().notNull().default('active'), createdAt: timestamp() .notNull() diff --git a/src/lib/permissions/index.test.ts b/src/lib/permissions/index.test.ts index 9f42d30..89e4bfa 100644 --- a/src/lib/permissions/index.test.ts +++ b/src/lib/permissions/index.test.ts @@ -3,6 +3,7 @@ import { isValidPermissionGrant, normalizePermissionGrant, normalizePermissionGrants, + toStoredPermissionGrant, } from './index'; describe('isValidPermissionGrant', () => { @@ -12,6 +13,8 @@ describe('isValidPermissionGrant', () => { expect(isValidPermissionGrant('project:project:all')).toBe(true); expect(isValidPermissionGrant('project:server-keys:read')).toBe(true); expect(isValidPermissionGrant('project:vault:secrets:import')).toBe(true); + expect(isValidPermissionGrant('organization:users:invite')).toBe(true); + expect(isValidPermissionGrant('project:project:all')).toBe(true); }); it('rejects grants without a scope, unknown actions and malformed strings', () => { @@ -20,9 +23,25 @@ describe('isValidPermissionGrant', () => { expect(isValidPermissionGrant('project:vault:secrets:frobnicate')).toBe(false); expect(isValidPermissionGrant('project')).toBe(false); expect(isValidPermissionGrant('*')).toBe(false); + // stripped forms with the scope prefix removed are no longer a valid stored grant — + // CanCanService checks the full catalog string verbatim, with no scope reconstruction. + expect(isValidPermissionGrant('project:all')).toBe(false); + expect(isValidPermissionGrant('users:invite')).toBe(false); + expect(isValidPermissionGrant('vault:secrets:import')).toBe(false); }); }); +describe('toStoredPermissionGrant', () => { + it('stores the catalog permission string as-is — CanCanService checks it verbatim', () => { + expect(toStoredPermissionGrant('project:project:all', 'project')).toBe('project:project:all'); + expect(toStoredPermissionGrant('project:vault:secrets:read', 'project')).toBe( + 'project:vault:secrets:read', + ); + expect(toStoredPermissionGrant('organization:users:invite', 'organization')).toBe( + 'organization:users:invite', + ); + }); +}); describe('normalizePermissionGrant', () => { it('leaves canonical grants untouched', () => { expect(normalizePermissionGrant('project:vault:secrets:read', 'project')).toBe( diff --git a/src/lib/permissions/index.ts b/src/lib/permissions/index.ts index 9d2026a..6ad25f5 100644 --- a/src/lib/permissions/index.ts +++ b/src/lib/permissions/index.ts @@ -20,6 +20,16 @@ function collectPermissionGrants(section: PermissionConfigSection): string[] { ]; } +// historically stripped the leading scope segment for a shorter "canonical" stored form, but +// CanCanService.hasPermission compares raw strings with no scope-aware reconstruction — the +// catalog permission string (e.g. 'organization:projects:read') IS the stored/checked grant. +export function toStoredPermissionGrant( + permission: string, + _scope: PermissionScope, +): PermissionGrant { + return permission; +} + /** * Grants are always canonical: `::`, exactly as declared in the * catalog (`project:vault:secrets:read`, `organization:projects:create`, `cluster:users:invite`). diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 08151d8..27daa0d 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -3,14 +3,7 @@ import { PasswordService } from './password.service'; import { SessionService } from './session.service'; import { UserRepository } from '../infrastructure/repositories/user.repository'; import { RoleRepository } from '../infrastructure/repositories/role.repository'; - -const CLUSTER_ADMIN_PERMISSIONS = [ - 'cluster:organization:all', - 'cluster:projects:all', - 'cluster:users:all', - 'cluster:settings:all', -]; -const CLUSTER_USER_PERMISSIONS: string[] = []; +import { CLUSTER_ADMIN_PERMISSIONS, CLUSTER_USER_PERMISSIONS } from '../domain/role-permissions.data'; export class AuthService { constructor( diff --git a/src/modules/auth/application/cancan.service.test.ts b/src/modules/auth/application/cancan.service.test.ts index a545f78..619ca93 100644 --- a/src/modules/auth/application/cancan.service.test.ts +++ b/src/modules/auth/application/cancan.service.test.ts @@ -166,6 +166,21 @@ describe('CanCanService', () => { ).toBe(false); }); + it('matches an N-segment wildcard grant against a narrower same-resource check', () => { + expect( + CanCanService.hasPermission(['organization:projects:all'], 'organization:projects:read'), + ).toBe(true); + expect( + CanCanService.hasPermission(['project:vault:secrets:all'], 'project:vault:secrets:read'), + ).toBe(true); + expect( + CanCanService.hasPermission(['organization:projects:all'], 'organization:users:read'), + ).toBe(false); + expect( + CanCanService.hasPermission(['organization:projects:read'], 'organization:projects:all'), + ).toBe(false); + }); + it('allows a cluster admin without organization or project access rows', async () => { userRepository.rows.set( 'jose', @@ -278,6 +293,191 @@ describe('CanCanService', () => { ); }); + describe('organization role authority cascades into its projects', () => { + it('lets organization:projects: satisfy the matching project:* check', async () => { + userRepository.rows.set('jose', user({ id: 'jose', role: null })); + projectLookup.organizationsByProjectId.set('kettu', 'gitops'); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ + id: 'org-developer', + slug: 'org-developer', + scope: 'organization', + permissions: ['organization:projects:read', 'organization:projects:update'], + }), + }), + ); + + await expect( + service.can('jose', 'project:project:read', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + await expect( + service.can('jose', 'project:vault:secrets:read', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + await expect( + service.can('jose', 'project:project:update', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + // org-developer has no organization:projects:delete grant, so nothing project-scoped + // that maps to "delete" should be authorized either + await expect( + service.can('jose', 'project:project:delete', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(false); + }); + + it('organization:projects:all satisfies every action inside the org projects', async () => { + userRepository.rows.set('jose', user({ id: 'jose', role: null })); + projectLookup.organizationsByProjectId.set('kettu', 'gitops'); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ + id: 'custom-org-role', + slug: 'custom-org-role', + scope: 'organization', + permissions: ['organization:projects:all'], + }), + }), + ); + + await expect( + service.can('jose', 'project:roles:delete', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + }); + + it('does not let unrelated organization permissions leak into project checks', async () => { + userRepository.rows.set('jose', user({ id: 'jose', role: null })); + projectLookup.organizationsByProjectId.set('kettu', 'gitops'); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ + id: 'org-users-manager', + slug: 'org-users-manager', + scope: 'organization', + permissions: ['organization:users:all'], + }), + }), + ); + + await expect( + service.can('jose', 'project:project:read', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(false); + }); + + it('a project-specific assignment wins over broader organization access (most-specific-wins)', async () => { + userRepository.rows.set('jose', user({ id: 'jose', role: null })); + userAccessRepository.rows.push( + // full authority over the organization's projects... + access({ + id: 'access-org', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ + id: 'org-full-access', + slug: 'org-full-access', + scope: 'organization', + permissions: ['organization:projects:all'], + }), + }), + // ...but explicitly only a viewer on this one project + access({ + id: 'access-project', + userId: 'jose', + scope: 'project', + projectId: 'kettu', + project: { id: 'kettu', organizationId: 'gitops' }, + role: role({ + id: 'project-viewer', + slug: 'project-viewer', + scope: 'project', + permissions: ['project:project:read'], + }), + }), + ); + + // the explicit project-viewer assignment is authoritative: read passes... + await expect( + service.can('jose', 'project:project:read', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + // ...but the organization's organization:projects:all must NOT leak through and grant + // write access the project-level role doesn't have. + await expect( + service.can('jose', 'project:project:update', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(false); + await expect( + service.can('jose', 'project:project:delete', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(false); + }); + }); + + describe('canManageOrganization / canViewOrganization', () => { + it('admits an organization-scope role, regardless of its permissions', async () => { + const jose = user({ id: 'jose', role: null }); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ id: 'org-developer', slug: 'org-developer', scope: 'organization' }), + }), + ); + + await expect(service.canManageOrganization(jose, 'gitops')).resolves.toBe(true); + await expect(service.canViewOrganization(jose, 'gitops')).resolves.toBe(true); + }); + + it('does not let a project-only user manage the parent organization', async () => { + const jose = user({ id: 'jose', role: null }); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'project', + projectId: 'kettu', + project: { id: 'kettu', organizationId: 'gitops' }, + role: role({ id: 'project-viewer', slug: 'project-viewer', scope: 'project' }), + }), + ); + + await expect(service.canManageOrganization(jose, 'gitops')).resolves.toBe(false); + }); + + it('lets a project-only user view (not manage) the parent organization', async () => { + const jose = user({ id: 'jose', role: null }); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'project', + projectId: 'kettu', + project: { id: 'kettu', organizationId: 'gitops' }, + role: role({ id: 'project-viewer', slug: 'project-viewer', scope: 'project' }), + }), + ); + + await expect(service.canViewOrganization(jose, 'gitops')).resolves.toBe(true); + await expect(service.canViewOrganization(jose, 'other-org')).resolves.toBe(false); + }); + + it('denies a user with no access rows at all', async () => { + const jose = user({ id: 'jose', role: null }); + await expect(service.canManageOrganization(jose, 'gitops')).resolves.toBe(false); + await expect(service.canViewOrganization(jose, 'gitops')).resolves.toBe(false); + }); + }); + describe('organizationIdsForUser', () => { it('returns null for a cluster admin (no restriction)', async () => { const jose = user({ id: 'jose', role: role({ id: 'admin', slug: 'admin' }) }); diff --git a/src/modules/auth/application/cancan.service.ts b/src/modules/auth/application/cancan.service.ts index 82ca3a9..01c609a 100644 --- a/src/modules/auth/application/cancan.service.ts +++ b/src/modules/auth/application/cancan.service.ts @@ -66,11 +66,29 @@ export class CanCanService { return this.can(user.id, permission, context); } + // coarse organization-scope membership check: gates the /settings area shell and nav visibility. + // does not cascade from project access — per-page/action checks decide what's actually visible. async canManageOrganization(user: PermissionAwareUser, organizationId: string): Promise { - return this.canSessionUser(user, 'organization:projects:read', { - scope: 'organization', - organizationId, - }); + if (!user?.id) return false; + if (this.isClusterAdmin(user.role ?? null)) return true; + + const access = await this.userAccessRepository.findByUserId(user.id); + return access.some( + (entry) => entry.scope === 'organization' && entry.organizationId === organizationId, + ); + } + + // read-only visibility into an organization: same as canManageOrganization, plus a user whose + // only access is to a project under this organization (they can see the org's overview, not + // manage it — every org-scope action still requires an actual organization:* permission grant). + async canViewOrganization(user: PermissionAwareUser, organizationId: string): Promise { + if (await this.canManageOrganization(user, organizationId)) return true; + if (!user?.id) return false; + + const access = await this.userAccessRepository.findByUserId(user.id); + return access.some( + (entry) => entry.scope === 'project' && entry.project?.organization?.id === organizationId, + ); } /** @@ -143,37 +161,64 @@ export class CanCanService { } const access = await this.userAccessRepository.findByUserId(user.id); - const candidateRoles = await this.rolesForContext(access, context); - return candidateRoles.some((role) => this.roleCan(role, permission)); + const candidates = await this.rolesForContext(access, context); + + return candidates.some(({ role, scope }) => { + // an organization-scope role reaching into one of its own projects doesn't hold any + // project:* grant directly (different namespace) — its authority over "this org's + // projects" as a whole (organization:projects:) carries the same action down + // into every project resource, top-down: cluster > organization > project. Only applies + // to the granular project:* vocabulary — legacy flat permissions (stateiac:*, vault:*) + // are scope-agnostic by design and keep matching literally. + if ( + context.scope === 'project' && + scope === 'organization' && + permission.startsWith('project:') + ) { + const action = permission.split(':').pop(); + return this.roleCan(role, `organization:projects:${action}` as PermissionGrant); + } + return this.roleCan(role, permission); + }); } private async rolesForContext( access: UserAccessDomain[], context: CanCanContext, - ): Promise { + ): Promise<{ role: RoleDomain; scope: 'organization' | 'project' }[]> { if (context.scope === 'organization') { return access .filter( (entry) => entry.scope === 'organization' && entry.organizationId === context.organizationId, ) - .map((entry) => entry.role) - .filter((role): role is RoleDomain => Boolean(role)); + .filter((entry): entry is UserAccessDomain & { role: RoleDomain } => Boolean(entry.role)) + .map((entry) => ({ role: entry.role, scope: 'organization' as const })); } if (context.scope !== 'project') return []; + // most-specific-wins: an explicit project-level assignment for this exact project is + // authoritative on its own — it does not get supplemented (or overridden) by whatever the + // user's organization role would otherwise allow. The organization role only cascades down + // when the user has no project-level assignment here at all. + const projectEntries = access.filter( + (entry) => entry.scope === 'project' && entry.projectId === context.projectId, + ); + + if (projectEntries.length > 0) { + return projectEntries + .filter((entry): entry is UserAccessDomain & { role: RoleDomain } => Boolean(entry.role)) + .map((entry) => ({ role: entry.role, scope: 'project' as const })); + } + const organizationId = context.organizationId ?? (await this.findProjectOrganizationId(context.projectId)); return access - .filter((entry) => { - if (entry.scope === 'project') return entry.projectId === context.projectId; - if (entry.scope === 'organization') return entry.organizationId === organizationId; - return false; - }) - .map((entry) => entry.role) - .filter((role): role is RoleDomain => Boolean(role)); + .filter((entry) => entry.scope === 'organization' && entry.organizationId === organizationId) + .filter((entry): entry is UserAccessDomain & { role: RoleDomain } => Boolean(entry.role)) + .map((entry) => ({ role: entry.role, scope: 'organization' as const })); } private roleCan(role: PermissionRole, permission: PermissionGrant): boolean { @@ -197,12 +242,18 @@ export class CanCanService { ): boolean { if (!grants || grants.length === 0) return false; - // grants are canonical (`project:vault:secrets:read`), and a `:all` - // grant covers every action on that resource + const [section] = permission.split(':') as [string, string]; + const lastColon = permission.lastIndexOf(':'); + const resourcePath = lastColon === -1 ? permission : permission.slice(0, lastColon); + const resourceWildcard = `${resourcePath}:all`; + return grants.some( (grant) => grant === permission || - (grant.endsWith(':all') && permission.startsWith(grant.slice(0, -'all'.length))), + grant === `${section}:all` || + grant === resourceWildcard || + grant.endsWith(`:${permission}`) || + grant.endsWith(`:${section}:all`), ); } diff --git a/src/modules/auth/application/role.service.test.ts b/src/modules/auth/application/role.service.test.ts index 50e4ba2..ea7d346 100644 --- a/src/modules/auth/application/role.service.test.ts +++ b/src/modules/auth/application/role.service.test.ts @@ -53,15 +53,29 @@ class FakeUserRepository { } } +class FakeUserAccessRepository { + roleIdCounts = new Map(); + + async countByRoleId(roleId: string) { + return this.roleIdCounts.get(roleId) ?? 0; + } +} + describe('RoleService', () => { let roleRepository: FakeRoleRepository; let userRepository: FakeUserRepository; + let userAccessRepository: FakeUserAccessRepository; let service: RoleService; beforeEach(async () => { roleRepository = new FakeRoleRepository(); userRepository = new FakeUserRepository(); - service = new RoleService(roleRepository as any, userRepository as any); + userAccessRepository = new FakeUserAccessRepository(); + service = new RoleService( + roleRepository as any, + userRepository as any, + userAccessRepository as any, + ); await roleRepository.create({ id: 'admin-id', @@ -197,6 +211,13 @@ describe('RoleService', () => { await expect(service.deleteRole(created.id)).rejects.toThrow(/assigned to existing users/); }); + it('blocks deleting a role assigned via a scoped user_access row', async () => { + const created = await service.createRole({ name: 'Auditor', slug: 'auditor', permissions: [] }); + userAccessRepository.roleIdCounts.set(created.id, 1); + + await expect(service.deleteRole(created.id)).rejects.toThrow(/assigned to existing users/); + }); + it('deletes a role with no users assigned', async () => { const created = await service.createRole({ name: 'Auditor', slug: 'auditor', permissions: [] }); diff --git a/src/modules/auth/application/role.service.ts b/src/modules/auth/application/role.service.ts index c941957..649cd9f 100644 --- a/src/modules/auth/application/role.service.ts +++ b/src/modules/auth/application/role.service.ts @@ -2,81 +2,15 @@ import crypto from 'crypto'; import { isValidPermissionGrant, normalizePermissionGrants } from '$lib/permissions'; import type { RoleRepository } from '../infrastructure/repositories/role.repository'; import type { UserRepository } from '../infrastructure/repositories/user.repository'; +import type { UserAccessRepository } from '../infrastructure/repositories/user-access.repository'; import type { RoleScope } from '../domain/role.domain'; - -const ORGANIZATION_ADMIN_PERMISSIONS = [ - 'organization:projects:all', - 'organization:users:all', - 'organization:roles:all', - 'organization:backups:all', - 'organization:server-keys:all', - 'organization:audit:all', -]; - -const ORGANIZATION_DEVELOPER_PERMISSIONS = [ - 'organization:projects:read', - 'organization:projects:create', - 'organization:projects:update', -]; - -const PROJECT_ADMIN_PERMISSIONS = [ - 'project:project:all', - 'project:users:all', - 'project:roles:all', - 'project:server-keys:all', - 'project:audit:all', - 'project:vault:secrets:all', - 'project:vault:environments:all', - 'project:codereport:reports:all', - 'project:codereport:dependencies:all', - 'project:codereport:vulnerabilities:all', - 'project:stateiac:stacks:all', - 'project:stateiac:states:all', - 'project:stateiac:history:all', -]; - -const PROJECT_DEVELOPER_PERMISSIONS = [ - 'project:vault:secrets:read', - 'project:vault:secrets:create', - 'project:vault:secrets:update', - 'project:vault:environments:read', - 'project:vault:environments:create', - 'project:vault:environments:update', - 'project:codereport:reports:read', - 'project:codereport:reports:create', - 'project:codereport:reports:update', - 'project:codereport:dependencies:read', - 'project:codereport:dependencies:create', - 'project:codereport:dependencies:update', - 'project:codereport:vulnerabilities:read', - 'project:codereport:vulnerabilities:create', - 'project:codereport:vulnerabilities:update', - 'project:stateiac:stacks:read', - 'project:stateiac:stacks:create', - 'project:stateiac:stacks:update', - 'project:stateiac:states:read', - 'project:stateiac:states:create', - 'project:stateiac:states:update', - 'project:stateiac:history:read', - 'project:stateiac:history:create', - 'project:stateiac:history:update', -]; - -const PROJECT_VIEWER_PERMISSIONS = [ - 'project:project:read', - 'project:users:read', - 'project:roles:read', - 'project:server-keys:read', - 'project:audit:read', - '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', -]; +import { + ORGANIZATION_ADMIN_PERMISSIONS, + ORGANIZATION_DEVELOPER_PERMISSIONS, + PROJECT_ADMIN_PERMISSIONS, + PROJECT_DEVELOPER_PERMISSIONS, + PROJECT_VIEWER_PERMISSIONS, +} from '../domain/role-permissions.data'; export class RoleService { constructor( @@ -85,6 +19,7 @@ export class RoleService { 'findAll' | 'findById' | 'findBySlug' | 'create' | 'update' | 'deleteById' >, private readonly userRepository: Pick, + private readonly userAccessRepository: Pick, ) {} async listRoles(scope: RoleScope = 'cluster', scopeId?: string): Promise { @@ -245,6 +180,11 @@ export class RoleService { throw new Error('Cannot delete a role that is assigned to existing users.'); } + const accessWithRole = await this.userAccessRepository.countByRoleId(id); + if (accessWithRole > 0) { + throw new Error('Cannot delete a role that is assigned to existing users.'); + } + await this.roleRepository.deleteById(id); } diff --git a/src/modules/auth/domain/role-permissions.data.ts b/src/modules/auth/domain/role-permissions.data.ts new file mode 100644 index 0000000..f897ff2 --- /dev/null +++ b/src/modules/auth/domain/role-permissions.data.ts @@ -0,0 +1,84 @@ +export const CLUSTER_ADMIN_PERMISSIONS = [ + 'cluster:organization:all', + 'cluster:projects:all', + 'cluster:users:all', + 'cluster:settings:all', +]; + +export const CLUSTER_USER_PERMISSIONS: string[] = []; + +export const ORGANIZATION_ADMIN_PERMISSIONS = [ + 'organization:projects:all', + 'organization:users:all', + 'organization:roles:all', + 'organization:settings:all', + 'organization:backups:all', + 'organization:server-keys:all', + 'organization:audit:all', +]; + +export const ORGANIZATION_DEVELOPER_PERMISSIONS = [ + 'organization:projects:read', + 'organization:projects:create', + 'organization:projects:update', +]; + +export const PROJECT_ADMIN_PERMISSIONS = [ + 'project:project:all', + 'project:users:all', + 'project:roles:all', + 'project:server-keys:all', + 'project:audit:all', + 'project:vault:secrets:all', + 'project:vault:environments:all', + 'project:codereport:reports:all', + 'project:codereport:dependencies:all', + 'project:codereport:vulnerabilities:all', + 'project:stateiac:stacks:all', + 'project:stateiac:states:all', + 'project:stateiac:history:all', +]; + +export const PROJECT_DEVELOPER_PERMISSIONS = [ + 'project:project:read', + 'project:vault:secrets:read', + 'project:vault:secrets:create', + 'project:vault:secrets:update', + 'project:vault:environments:read', + 'project:vault:environments:create', + 'project:vault:environments:update', + 'project:codereport:reports:read', + 'project:codereport:reports:create', + 'project:codereport:reports:update', + 'project:codereport:dependencies:read', + 'project:codereport:dependencies:create', + 'project:codereport:dependencies:update', + 'project:codereport:vulnerabilities:read', + 'project:codereport:vulnerabilities:create', + 'project:codereport:vulnerabilities:update', + 'project:stateiac:stacks:read', + 'project:stateiac:stacks:create', + 'project:stateiac:stacks:update', + 'project:stateiac:states:read', + 'project:stateiac:states:create', + 'project:stateiac:states:update', + 'project:stateiac:history:read', + 'project:stateiac:history:create', + 'project:stateiac:history:update', +]; + +export const PROJECT_VIEWER_PERMISSIONS = [ + 'project:project:read', + 'project:users:read', + 'project:roles:read', + 'project:server-keys:read', + 'project:audit:read', + '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', +]; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index 716b0ec..cafbdf3 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -33,7 +33,7 @@ export const authService = new AuthService( ); export const userService = new UserService(userRepository, roleRepository, passwordService); export const profileService = new ProfileService(userRepository, passwordService); -export const roleService = new RoleService(roleRepository, userRepository); +export const roleService = new RoleService(roleRepository, userRepository, userAccessRepository); export const invitationService = new InvitationService( userRepository, userAccessRepository, diff --git a/src/modules/auth/infrastructure/repositories/user-access.repository.ts b/src/modules/auth/infrastructure/repositories/user-access.repository.ts index 4cdfea4..0b50f59 100644 --- a/src/modules/auth/infrastructure/repositories/user-access.repository.ts +++ b/src/modules/auth/infrastructure/repositories/user-access.repository.ts @@ -9,7 +9,7 @@ import { Repository } from './repository'; export class UserAccessRepository extends Repository { async findAll(): Promise { const result = await this.db - .with({ user: true, role: true, organization: true, project: true }) + .with({ user: true, role: true, organization: true, project: { organization: true } }) .select() .from(UserAccessEntity) .orderBy('createdAt', 'asc'); @@ -18,7 +18,7 @@ export class UserAccessRepository extends Repository { async findByUserId(userId: string): Promise { const result = await this.db - .with({ user: true, role: true, organization: true, project: true }) + .with({ user: true, role: true, organization: true, project: { organization: true } }) .select() .from(UserAccessEntity) .where({ userId }) @@ -28,7 +28,7 @@ export class UserAccessRepository extends Repository { async findById(id: string): Promise { const result = await this.db - .with({ user: true, role: true, organization: true, project: true }) + .with({ user: true, role: true, organization: true, project: { organization: true } }) .select() .from(UserAccessEntity) .where({ id }) @@ -118,4 +118,9 @@ export class UserAccessRepository extends Repository { async deleteById(id: string): Promise { await this.db.delete(UserAccessEntity).where({ id }); } + + async countByRoleId(roleId: string): Promise { + const result = await this.db.select().from(UserAccessEntity).where({ roleId }); + return result.rows.length; + } } diff --git a/src/modules/code-report/application/code-report-analysis.service.ts b/src/modules/code-report/application/code-report-analysis.service.ts index 2097678..fd013f7 100644 --- a/src/modules/code-report/application/code-report-analysis.service.ts +++ b/src/modules/code-report/application/code-report-analysis.service.ts @@ -4,7 +4,8 @@ import type { CodeReportAnalysisDomain } from '../domain/code-report-analysis.do import type { CodeReportGitInfo } from '../domain/code-report-analysis.domain'; import { extractSecrets, extractVulnerabilities } from '$lib/code-report/analysis-summary'; import { evaluatePolicies, type PolicyComplianceReport } from '$lib/code-report/policy-evaluation'; -import type { SecurityPolicy, SecurityPolicyType } from '$lib/code-report/security-policy'; +import type { SecurityPolicy } from '$lib/code-report/security-policy'; +import { TOOL_POLICY_TYPES, DEFAULT_POLICY_TYPES } from '../domain/tool-policy-types.data'; type ServiceLookup = { findById(id: string): Promise<{ id: string; projectId?: string; tags?: string[] } | null>; @@ -14,19 +15,6 @@ type PolicyLookup = { listByProject(projectId: string): Promise; }; -// each tool only produces evidence for some policy types -const TOOL_POLICY_TYPES: Record = { - trivy: ['vulnerabilities', 'license'], - grype: ['vulnerabilities'], - sbom: ['license'], - syft: ['license'], - gitleaks: ['secrets'], - trufflehog: ['secrets'], - coverage: ['code_coverage'], - 'code-coverage': ['code_coverage'], -}; -const DEFAULT_POLICY_TYPES: SecurityPolicyType[] = ['vulnerabilities', 'license']; - export class CodeReportAnalysisService { constructor( private readonly repository: CodeReportAnalysisRepository, diff --git a/src/modules/code-report/application/code-report.service.ts b/src/modules/code-report/application/code-report.service.ts index 6672bea..7c867e4 100644 --- a/src/modules/code-report/application/code-report.service.ts +++ b/src/modules/code-report/application/code-report.service.ts @@ -1,13 +1,7 @@ import crypto from 'crypto'; import { CodeReportServiceRepository } from '../infrastructure/repositories/code-report-service.repository'; import { ProjectService } from '../../projects/application/project.service'; - -export type RiskWeights = { - critical: number; - high: number; - medium: number; - low: number; -}; +import { DEFAULT_RISK_WEIGHTS, type RiskWeights } from '../domain/risk-weights.data'; export type VulnerabilityTotals = { critical: number; @@ -16,13 +10,6 @@ export type VulnerabilityTotals = { low: number; }; -export const DEFAULT_RISK_WEIGHTS: RiskWeights = { - critical: 10, - high: 6, - medium: 3, - low: 1, -}; - type AnalysisCleanup = { deleteAllByService(serviceId: string): Promise; }; diff --git a/src/modules/code-report/domain/risk-weights.data.ts b/src/modules/code-report/domain/risk-weights.data.ts new file mode 100644 index 0000000..1dfde7e --- /dev/null +++ b/src/modules/code-report/domain/risk-weights.data.ts @@ -0,0 +1,13 @@ +export type RiskWeights = { + critical: number; + high: number; + medium: number; + low: number; +}; + +export const DEFAULT_RISK_WEIGHTS: RiskWeights = { + critical: 10, + high: 6, + medium: 3, + low: 1, +}; diff --git a/src/modules/code-report/domain/tool-policy-types.data.ts b/src/modules/code-report/domain/tool-policy-types.data.ts new file mode 100644 index 0000000..f6115b8 --- /dev/null +++ b/src/modules/code-report/domain/tool-policy-types.data.ts @@ -0,0 +1,15 @@ +import type { SecurityPolicyType } from '$lib/code-report/security-policy'; + +// each tool only produces evidence for some policy types +export const TOOL_POLICY_TYPES: Record = { + trivy: ['vulnerabilities', 'license'], + grype: ['vulnerabilities'], + sbom: ['license'], + syft: ['license'], + gitleaks: ['secrets'], + trufflehog: ['secrets'], + coverage: ['code_coverage'], + 'code-coverage': ['code_coverage'], +}; + +export const DEFAULT_POLICY_TYPES: SecurityPolicyType[] = ['vulnerabilities', 'license']; diff --git a/src/modules/projects/domain/project-defaults.data.ts b/src/modules/projects/domain/project-defaults.data.ts new file mode 100644 index 0000000..bde4553 --- /dev/null +++ b/src/modules/projects/domain/project-defaults.data.ts @@ -0,0 +1,44 @@ +export interface ProjectModules { + vault: boolean; + codereport: boolean; + stateiac: boolean; +} + +export const DEFAULT_PROJECT_MODULES: ProjectModules = { + vault: true, + codereport: true, + stateiac: true, +}; + +export interface ProjectSettings { + 'code-report': { + securityRiskMultipliers: { + critical: number; + high: number; + medium: number; + low: number; + }; + tools: { + id: string; + name: string; + description: string; + enabled: boolean; + scanner?: string; + soon?: boolean; + }[]; + }; +} + +export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { + 'code-report': { + securityRiskMultipliers: { + critical: 10, + high: 6, + medium: 3, + low: 1, + }, + tools: [ + { id: 'trivy', name: 'Trivy', description: 'Comprehensive security scanner', enabled: true }, + ], + }, +}; diff --git a/src/modules/projects/domain/project.domain.ts b/src/modules/projects/domain/project.domain.ts index 48e4d1e..2bfa6f0 100644 --- a/src/modules/projects/domain/project.domain.ts +++ b/src/modules/projects/domain/project.domain.ts @@ -1,56 +1,20 @@ import { Domain } from '$lib/server/domain/domain'; import { OrganizationDomain } from '../../organization/domain/organization.domain'; +import { + DEFAULT_PROJECT_MODULES, + DEFAULT_PROJECT_SETTINGS, + type ProjectModules, + type ProjectSettings, +} from './project-defaults.data'; + +export type { ProjectModules, ProjectSettings }; +export { DEFAULT_PROJECT_MODULES, DEFAULT_PROJECT_SETTINGS }; export interface ProjectStatus { ACTIVE: 'active'; INACTIVE: 'inactive'; } -export interface ProjectModules { - vault: boolean; - codereport: boolean; - stateiac: boolean; -} - -export const DEFAULT_PROJECT_MODULES: ProjectModules = { - vault: true, - codereport: true, - stateiac: true, -}; - -export interface ProjectSettings { - 'code-report': { - securityRiskMultipliers: { - critical: number; - high: number; - medium: number; - low: number; - }; - tools: { - id: string; - name: string; - description: string; - enabled: boolean; - scanner?: string; - soon?: boolean; - }[]; - }; -} - -export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { - 'code-report': { - securityRiskMultipliers: { - critical: 10, - high: 6, - medium: 3, - low: 1, - }, - tools: [ - { id: 'trivy', name: 'Trivy', description: 'Comprehensive security scanner', enabled: true }, - ] - } -}; - export class ProjectDomain extends Domain { public name: string = ''; public slug: string | null = null; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 045c670..2a264eb 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -35,6 +35,33 @@ export async function load({ locals, url }) { const canManageOrganization = organization ? await cancanService.canManageOrganization(locals.user, organization.id) : false; + + const orgSectionPermission = (permission: string) => + organization + ? cancanService.canSessionUser(locals.user, permission, { + scope: 'organization', + organizationId: organization.id, + }) + : Promise.resolve(false); + + const [ + canReadOrgProjects, + canReadOrgUsers, + canReadOrgRoles, + canReadOrgGlobal, + canReadOrgBackups, + canReadOrgServerKeys, + canReadOrgAudit, + ] = await Promise.all([ + orgSectionPermission('organization:projects:read'), + orgSectionPermission('organization:users:read'), + orgSectionPermission('organization:roles:read'), + orgSectionPermission('organization:settings:read'), + orgSectionPermission('organization:backups:read'), + orgSectionPermission('organization:server-keys:read'), + orgSectionPermission('organization:audit:read'), + ]); + const currentProjectSlug = url.pathname.match(/\/projects\/([^/]+)/)?.[1] ?? null; const projects = locals.user @@ -44,11 +71,11 @@ export async function load({ locals, url }) { .filter((project) => project.status === 'active') .map(async (project) => ({ project, - allowed: await cancanService.canSessionUser(locals.user, 'project:project:read', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }), + allowed: await cancanService.canManageProject( + locals.user, + project.id, + project.organization?.id, + ), })), ) ) @@ -68,6 +95,53 @@ export async function load({ locals, url }) { ) : false; + const projectSectionPermission = (permission: string) => + currentProject + ? cancanService.canSessionUser(locals.user, permission, { + scope: 'project', + projectId: currentProject.id, + organizationId: currentProject.organization?.id, + }) + : Promise.resolve(false); + + const [ + canReadProjectInfo, + canReadProjectUsers, + canReadProjectRoles, + canReadProjectServerKeys, + canReadProjectAudit, + canReadProjectVaultSecrets, + canReadProjectVaultEnvironments, + canReadProjectCodeReportReports, + canReadProjectCodeReportDependencies, + canReadProjectCodeReportVulnerabilities, + canReadProjectStateIacStacks, + canReadProjectStateIacStates, + canReadProjectStateIacHistory, + ] = await Promise.all([ + projectSectionPermission('project:project:read'), + projectSectionPermission('project:users:read'), + projectSectionPermission('project:roles:read'), + projectSectionPermission('project:server-keys:read'), + projectSectionPermission('project:audit:read'), + projectSectionPermission('project:vault:secrets:read'), + projectSectionPermission('project:vault:environments:read'), + projectSectionPermission('project:codereport:reports:read'), + projectSectionPermission('project:codereport:dependencies:read'), + projectSectionPermission('project:codereport:vulnerabilities:read'), + projectSectionPermission('project:stateiac:stacks:read'), + projectSectionPermission('project:stateiac:states:read'), + projectSectionPermission('project:stateiac:history:read'), + ]); + + const canReadProjectVault = canReadProjectVaultSecrets || canReadProjectVaultEnvironments; + const canReadProjectCodeReport = + canReadProjectCodeReportReports || + canReadProjectCodeReportDependencies || + canReadProjectCodeReportVulnerabilities; + const canReadProjectStateIac = + canReadProjectStateIacStacks || canReadProjectStateIacStates || canReadProjectStateIacHistory; + return { // isConfigured: !!config && backends.length > 0, isConfigured: true, @@ -79,6 +153,21 @@ export async function load({ locals, url }) { canAccessClusterSettings, canManageOrganization, canManageProject, + canReadOrgProjects, + canReadOrgUsers, + canReadOrgRoles, + canReadOrgGlobal, + canReadOrgBackups, + canReadOrgServerKeys, + canReadOrgAudit, + canReadProjectInfo, + canReadProjectUsers, + canReadProjectRoles, + canReadProjectServerKeys, + canReadProjectAudit, + canReadProjectVault, + canReadProjectCodeReport, + canReadProjectStateIac, currentProjectSlug, }; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 342a9e2..c886724 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -54,6 +54,21 @@ canAccessClusterSettings={data.canAccessClusterSettings} canManageOrganization={data.canManageOrganization} canManageProject={data.canManageProject} + canReadOrgProjects={data.canReadOrgProjects} + canReadOrgUsers={data.canReadOrgUsers} + canReadOrgRoles={data.canReadOrgRoles} + canReadOrgGlobal={data.canReadOrgGlobal} + canReadOrgBackups={data.canReadOrgBackups} + canReadOrgServerKeys={data.canReadOrgServerKeys} + canReadOrgAudit={data.canReadOrgAudit} + canReadProjectInfo={data.canReadProjectInfo} + canReadProjectUsers={data.canReadProjectUsers} + canReadProjectRoles={data.canReadProjectRoles} + canReadProjectServerKeys={data.canReadProjectServerKeys} + canReadProjectAudit={data.canReadProjectAudit} + canReadProjectVault={data.canReadProjectVault} + canReadProjectCodeReport={data.canReadProjectCodeReport} + canReadProjectStateIac={data.canReadProjectStateIac} currentProjectSlug={data.currentProjectSlug ?? null} bind:collapsed={sidebarCollapsed} /> diff --git a/src/routes/bootstrap/+page.server.ts b/src/routes/bootstrap/+page.server.ts index 5611800..1c4200a 100644 --- a/src/routes/bootstrap/+page.server.ts +++ b/src/routes/bootstrap/+page.server.ts @@ -57,11 +57,13 @@ export const actions = { try { const form = await request.formData(); const { organizationService } = await import('$modules/organization'); - await organizationService.createOrganization({ + const { roleService } = await import('$modules/auth'); + const organization = await organizationService.createOrganization({ name: String(form.get('name') ?? ''), slug: String(form.get('slug') ?? '') || undefined, description: String(form.get('description') ?? '') || undefined, }); + await roleService.createDefaultOrganizationRoles(organization.id); await refreshBootstrapState(); } catch (error: unknown) { diff --git a/src/routes/cluster-settings/access-control/+page.svelte b/src/routes/cluster-settings/access-control/+page.svelte index 5ad3c4d..4adee26 100644 --- a/src/routes/cluster-settings/access-control/+page.svelte +++ b/src/routes/cluster-settings/access-control/+page.svelte @@ -11,4 +11,7 @@ roles={data.roles} title={$_('clusterSettings.users.title')} description={$_('clusterSettings.users.description')} + canCreate={true} + canUpdate={true} + canDelete={true} /> diff --git a/src/routes/cluster-settings/orgs/+page.server.ts b/src/routes/cluster-settings/orgs/+page.server.ts index f8a72e2..f7ebd63 100644 --- a/src/routes/cluster-settings/orgs/+page.server.ts +++ b/src/routes/cluster-settings/orgs/+page.server.ts @@ -1,5 +1,5 @@ import { fail } from '@sveltejs/kit'; -import { cancanService } from '$modules/auth'; +import { cancanService, roleService } from '$modules/auth'; import { organizationService } from '$modules/organization'; function errorResponse(error: unknown) { @@ -24,6 +24,7 @@ export const actions = { slug: String(form.get('slug') ?? '') || undefined, description: String(form.get('description') ?? '') || undefined, }); + await roleService.createDefaultOrganizationRoles(organization.id); return { success: true, organization }; } catch (error: unknown) { return errorResponse(error); diff --git a/src/routes/org/[org]/overview/+page.server.ts b/src/routes/org/[org]/overview/+page.server.ts index 1e9dc07..ec37a1c 100644 --- a/src/routes/org/[org]/overview/+page.server.ts +++ b/src/routes/org/[org]/overview/+page.server.ts @@ -5,12 +5,7 @@ import { cancanService } from '$modules/auth'; export async function load({ locals, parent }) { const { organization } = await parent(); - if ( - !(await cancanService.canSessionUser(locals.user, 'organization:projects:read', { - scope: 'organization', - organizationId: organization.id, - })) - ) { + if (!(await cancanService.canViewOrganization(locals.user, organization.id))) { throw error(403, 'Forbidden'); } diff --git a/src/routes/org/[org]/projects/[slug]/+layout.server.ts b/src/routes/org/[org]/projects/[slug]/+layout.server.ts index 37a2d1b..c44db7c 100644 --- a/src/routes/org/[org]/projects/[slug]/+layout.server.ts +++ b/src/routes/org/[org]/projects/[slug]/+layout.server.ts @@ -6,11 +6,7 @@ export async function load({ params, locals }) { try { const project = await projectService.getProjectBySlug(params.slug); const organizationId = project.organization?.id; - const canRead = await cancanService.canSessionUser(locals.user, 'project:project:read', { - scope: 'project', - projectId: project.id, - organizationId, - }); + const canRead = await cancanService.canManageProject(locals.user, project.id, organizationId); if (!canRead) { throw error(403, 'Forbidden'); diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts index 1c41e7c..e0c0acd 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts @@ -21,6 +21,16 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } + const canCreate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:create', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + const services = await codeReportService.listByProject(project.id); const servicesWithSeverity = await Promise.all( @@ -38,7 +48,7 @@ export async function load({ parent, locals }) { }), ); - return { services: servicesWithSeverity }; + return { services: servicesWithSeverity, canCreate }; } export const actions = { @@ -70,7 +80,7 @@ export const actions = { try { await codeReportService.createService({ - projectId: project.id, + project: params.slug, name, slug: slug || undefined, description: description || undefined, diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte index c0bbff2..5cc6463 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte @@ -32,6 +32,7 @@ export let data: { services: ServiceRow[]; project?: { slug?: string; organization?: { slug?: string | null } | null }; + canCreate: boolean; }; export let form: { success?: boolean; @@ -113,14 +114,16 @@ class="w-full rounded-full border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-sm text-slate-900 shadow-sm focus:border-slate-400 focus:outline-none" />
- + {#if data.canCreate} + + {/if}
{#if filteredServices.length === 0} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts index e0ddcf2..042e185 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts @@ -20,6 +20,19 @@ export async function load({ parent, params, locals }) { throw error(403, 'Forbidden'); } + const [canCreate, canDelete] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:codereport:reports:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:codereport:reports:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + ]); + try { const service = await codeReportService.getByProjectIdAndSlug(project.id, params.serviceSlug); const analyses = await codeReportAnalysisService.listByService(service.id); @@ -45,7 +58,15 @@ export async function load({ parent, params, locals }) { const riskWeights = await codeReportService.getRiskWeightsByProjectId(project.id); - return { service, latestAnalysis, latestByTool, analysisHistory, riskWeights }; + return { + service, + latestAnalysis, + latestByTool, + analysisHistory, + riskWeights, + canCreate, + canDelete, + }; } catch { throw error(404, 'Service not found'); } diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte index 6a0c00d..c1cec61 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte @@ -12,6 +12,8 @@ analysisHistory: any[]; riskWeights: { critical: number; high: number; medium: number; low: number }; project?: { slug?: string; organization?: { slug?: string | null } | null }; + canCreate: boolean; + canDelete: boolean; }; export let form: { error?: string; @@ -45,16 +47,18 @@ > {$_('codeReport.serviceDetail.viewHistory')} - + {#if data.canDelete} + + {/if}
diff --git a/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.server.ts index c6c6c8b..644103f 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.server.ts @@ -1,12 +1,37 @@ -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; import { projectService } from '$modules/projects'; -export async function load({ params }) { +export async function load({ params, locals }) { const project = await projectService.getProjectBySlug(params.slug); if (!project) { throw error(404, 'Proyecto no encontrado'); } + const canRead = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:read', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:update', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + const codeReportSettings = project.settings?.['code-report'] || { securityRiskMultipliers: { critical: 10, @@ -18,19 +43,34 @@ export async function load({ params }) { return { settings: codeReportSettings, + canUpdate, }; } export const actions = { - updateRiskMultipliers: async ({ request, params }) => { + updateRiskMultipliers: async ({ request, params, locals }) => { + const project = await projectService.getProjectBySlug(params.slug); + + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:update', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + + if (!canUpdate) { + return fail(403, { error: 'Forbidden' }); + } + const data = await request.formData(); const critical = Number(data.get('critical')); const high = Number(data.get('high')); const medium = Number(data.get('medium')); const low = Number(data.get('low')); - const project = await projectService.getProjectBySlug(params.slug); - const currentSettings = project.settings || {}; const codeReportSettings = currentSettings['code-report'] || {}; diff --git a/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.svelte index eedc7c1..5bb68f0 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.svelte @@ -7,6 +7,7 @@ $: settings = data.settings; $: multipliers = settings.securityRiskMultipliers; + $: canUpdate = data.canUpdate; let loading = false; @@ -47,22 +48,22 @@
@@ -82,11 +83,13 @@

{$_('codeReport.settings.moreSettingsDescription')}

-
- -
+ {#if canUpdate} +
+ +
+ {/if} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.server.ts index f686f50..e956559 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.server.ts @@ -1,3 +1,5 @@ +import { error, fail } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; import { projectService } from '$modules/projects'; const DEFAULT_CODE_REPORT_TOOLS = [ @@ -32,9 +34,33 @@ const DEFAULT_CODE_REPORT_TOOLS = [ }, ]; -export async function load({ params }) { +export async function load({ params, locals }) { const project = await projectService.getProjectBySlug(params.slug); + const canRead = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:read', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:update', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + const codeReportSettings = project.settings?.['code-report'] || {}; const persistedTools = Array.isArray(codeReportSettings.tools) ? codeReportSettings.tools : []; const persistedById = new Map( @@ -53,16 +79,31 @@ export async function load({ params }) { return { tools, + canUpdate, }; } export const actions = { - updateTools: async ({ request, params }) => { + updateTools: async ({ request, params, locals }) => { + const project = await projectService.getProjectBySlug(params.slug); + + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:update', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + + if (!canUpdate) { + return fail(403, { error: 'Forbidden' }); + } + const data = await request.formData(); const enabledToolIds = data.getAll('tools'); - const project = await projectService.getProjectBySlug(params.slug); - const currentSettings = project.settings || {}; const codeReportSettings = currentSettings['code-report'] || {}; const enabledSet = new Set(enabledToolIds.map((id) => String(id))); diff --git a/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.svelte index a262750..8f70c94 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.svelte @@ -15,6 +15,7 @@ }; $: tools = data.tools as AnalysisTool[]; + $: canUpdate = data.canUpdate; let localTools: AnalysisTool[] = []; $: localTools = tools.map((tool: AnalysisTool) => ({ ...tool })); @@ -50,7 +51,7 @@ {#each localTools as tool}
- +
-
- -
+ {#if canUpdate} +
+ +
+ {/if}
diff --git a/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.server.ts index d295de0..5117ab1 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import type { AuthenticatedUser } from '$modules/auth/domain/entities'; import { cancanService, roleService, userAccessService } from '$modules/auth'; import { projectService } from '$modules/projects'; @@ -10,9 +10,14 @@ function errorResponse(error: unknown) { async function canUpdateProjectUsers( user: AuthenticatedUser | null | undefined, projectSlug: string, + permission: + | 'project:users:create' + | 'project:users:update' + | 'project:users:delete' + | 'project:users:invite', ) { const project = await projectService.getProjectBySlug(projectSlug); - const allowed = await cancanService.canSessionUser(user, 'project:users:update', { + const allowed = await cancanService.canSessionUser(user, permission, { scope: 'project', projectId: project.id, organizationId: project.organization?.id, @@ -20,19 +25,49 @@ async function canUpdateProjectUsers( return { project, allowed }; } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { project } = await parent(); - const [users, roles, assignableUsers] = await Promise.all([ + + const canRead = await cancanService.canSessionUser(locals.user, 'project:users:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const [users, roles, assignableUsers, canCreate, canUpdate, canDelete] = await Promise.all([ userAccessService.listUsers('project', project.id), roleService.listRoles('project', project.id), userAccessService.listAssignableUsers(), + cancanService.canSessionUser(locals.user, 'project:users:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:users:update', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:users:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), ]); - return { project, users, roles, assignableUsers }; + return { project, users, roles, assignableUsers, canCreate, canUpdate, canDelete }; } export const actions = { async addUser({ request, locals, params }) { - const { project, allowed } = await canUpdateProjectUsers(locals.user, params.slug); + const { project, allowed } = await canUpdateProjectUsers( + locals.user, + params.slug, + 'project:users:create', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { @@ -49,7 +84,11 @@ export const actions = { }, async updateUserAccess({ request, locals, params }) { - const { project, allowed } = await canUpdateProjectUsers(locals.user, params.slug); + const { project, allowed } = await canUpdateProjectUsers( + locals.user, + params.slug, + 'project:users:update', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { @@ -68,7 +107,11 @@ export const actions = { }, async removeUserAccess({ request, locals, params }) { - const { project, allowed } = await canUpdateProjectUsers(locals.user, params.slug); + const { project, allowed } = await canUpdateProjectUsers( + locals.user, + params.slug, + 'project:users:delete', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { @@ -85,7 +128,11 @@ export const actions = { }, async resendInvitation({ request, locals, params }) { - const { project, allowed } = await canUpdateProjectUsers(locals.user, params.slug); + const { project, allowed } = await canUpdateProjectUsers( + locals.user, + params.slug, + 'project:users:invite', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { diff --git a/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.svelte index e004888..9b1aa4f 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.svelte @@ -7,6 +7,9 @@ roles: any[]; assignableUsers: any[]; project: { name: string }; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; }; @@ -17,4 +20,7 @@ assignableUsers={data.assignableUsers} title={$_('projectSettings.users.title')} description={`${$_('projectSettings.users.descriptionPrefix')} ${data.project.name}${$_('projectSettings.users.descriptionSuffix')}`} + canCreate={data.canCreate} + canUpdate={data.canUpdate} + canDelete={data.canDelete} /> diff --git a/src/routes/org/[org]/projects/[slug]/settings/audit/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/audit/+page.server.ts new file mode 100644 index 0000000..e2aa3bb --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/settings/audit/+page.server.ts @@ -0,0 +1,18 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { project } = await parent(); + + const canRead = await cancanService.canSessionUser(locals.user, 'project:audit:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts index 0327754..05dcb3a 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts @@ -14,15 +14,28 @@ export async function load({ params, locals }) { throw error(404, 'Project not found'); } - const canRead = await cancanService.canSessionUser(locals.user, 'project:project:read', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }); + const canRead = await cancanService.canManageProject( + locals.user, + project.id, + project.organization?.id, + ); if (!canRead) throw error(403, 'Forbidden'); - return { project }; + const [canUpdate, canDelete] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:project:update', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:project:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + ]); + + return { project, canUpdate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte index 3d666de..ac43fa6 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte @@ -36,9 +36,10 @@ updatedAt: string; }; - export let data: { project: ProjectRow }; + export let data: { project: ProjectRow; canUpdate: boolean; canDelete: boolean }; $: project = data.project; + $: canUpdate = data.canUpdate; $: orgSlug = $page?.params?.org ?? ''; $: isArchived = project.status === 'inactive'; @@ -81,7 +82,9 @@ $: integrationCards = [ { label: 'GitOps CLI', - description: $_('projectSettings.overview.gitopsCliDescription', { values: { slug: data.project.slug } }), + description: $_('projectSettings.overview.gitopsCliDescription', { + values: { slug: data.project.slug }, + }), icon: Terminal, }, { @@ -194,8 +197,9 @@ await update(); if (result.type === 'failure') { - error = - result.data?.error ? String(result.data.error) : $_('projectSettings.overview.deleteFailed'); + error = result.data?.error + ? String(result.data.error) + : $_('projectSettings.overview.deleteFailed'); deleteLoading = false; } }; @@ -259,26 +263,44 @@ - + {#if canUpdate} + + {:else} +

+ {project.name} +

+ {/if}
- + {#if canUpdate} + + {:else} +

+ {project.slug} +

+ {/if}
@@ -286,14 +308,22 @@ - + {#if canUpdate} + + {:else} +

+ {project.description || $_('projectSettings.overview.optionalDescription')} +

+ {/if} @@ -315,7 +345,8 @@ - + {#if canUpdate} +
+ +
+ {/if}
-

{$_('projectSettings.overview.integrations')}

+

+ {$_('projectSettings.overview.integrations')} +

{$_('projectSettings.overview.integrationsDescription')}

@@ -404,80 +439,88 @@
-
-
-

{$_('projectSettings.overview.dangerZone')}

-
- -
-
-
-

- {$_('projectSettings.overview.transferToOrganization')} -

-

- {$_('projectSettings.overview.transferDescription')} -

-
- + {#if canUpdate || data.canDelete} +
+
+

+ {$_('projectSettings.overview.dangerZone')} +

-
-
-

- {isArchived - ? $_('projectSettings.overview.activateProject') - : $_('projectSettings.overview.archiveProject')} -

-

- {isArchived - ? $_('projectSettings.overview.activateDescription') - : $_('projectSettings.overview.archiveDescription')} -

-
- -
+
+ {#if canUpdate} +
+
+

+ {$_('projectSettings.overview.transferToOrganization')} +

+

+ {$_('projectSettings.overview.transferDescription')} +

+
+ +
-
-
-

- {$_('projectSettings.overview.deleteProject')} -

-

- {$_('projectSettings.overview.deleteDescription')} -

-
- +
+
+

+ {isArchived + ? $_('projectSettings.overview.activateProject') + : $_('projectSettings.overview.archiveProject')} +

+

+ {isArchived + ? $_('projectSettings.overview.activateDescription') + : $_('projectSettings.overview.archiveDescription')} +

+
+ +
+ {/if} + + {#if data.canDelete} +
+
+

+ {$_('projectSettings.overview.deleteProject')} +

+

+ {$_('projectSettings.overview.deleteDescription')} +

+
+ +
+ {/if}
-
-
+
+ {/if} {#if archiveModalOpen} @@ -508,13 +551,15 @@
{#if isArchived} - {$_('projectSettings.overview.activateConfirmationStart')} {project.name}{$_('projectSettings.overview.activateConfirmationEnd')} + {$_('projectSettings.overview.activateConfirmationStart')} + {project.name}{$_( + 'projectSettings.overview.activateConfirmationEnd', + )} {:else} - {$_('projectSettings.overview.archiveConfirmationStart')} {project.name}{$_('projectSettings.overview.archiveConfirmationEnd')} + {$_('projectSettings.overview.archiveConfirmationStart')} + {project.name}{$_( + 'projectSettings.overview.archiveConfirmationEnd', + )} {/if}
@@ -572,9 +617,10 @@
- {$_('projectSettings.overview.deleteConfirmationStart')} {project.name}{$_('projectSettings.overview.deleteConfirmationEnd')} + {$_('projectSettings.overview.deleteConfirmationStart')} + {project.name}{$_( + 'projectSettings.overview.deleteConfirmationEnd', + )}
diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts index 648fa19..67a91e5 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService } from '$modules/auth'; import { projectService } from '$modules/projects'; @@ -27,10 +27,28 @@ async function canManageProjectRole( return { project, allowed }; } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { project } = await parent(); - const roles = await roleService.listRoles('project', project.id); - return { roles }; + + const canRead = await cancanService.canSessionUser(locals.user, 'project:roles:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const [roles, canCreate] = await Promise.all([ + roleService.listRoles('project', project.id), + cancanService.canSessionUser(locals.user, 'project:roles:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + ]); + return { roles, canCreate }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.svelte index 6871b8d..8331036 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.svelte @@ -5,6 +5,7 @@ export let data: { project: { slug: string; organization: { slug: string } | null }; roles: any[]; + canCreate: boolean; }; $: baseHref = `/org/${data.project.organization?.slug ?? ''}/projects/${data.project.slug}/settings/roles-permissions`; @@ -15,4 +16,5 @@ title={$_('projectSettings.roles.title')} description={$_('projectSettings.roles.description')} {baseHref} + canCreate={data.canCreate} /> diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts index b6f8bbc..1d1e2ed 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts @@ -29,12 +29,35 @@ async function canManageProjectRole( return { project, allowed }; } -export async function load({ parent, params }) { +export async function load({ parent, params, locals }) { const { project } = await parent(); - const roles = await roleService.listRoles('project', project.id); + + const canRead = await cancanService.canSessionUser(locals.user, 'project:roles:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const [roles, canUpdate, canDelete] = await Promise.all([ + roleService.listRoles('project', project.id), + cancanService.canSessionUser(locals.user, 'project:roles:update', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:roles:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + ]); const role = roles.find((row) => row.id === params.id); if (!role) throw error(404, 'Role not found'); - return { project, role }; + return { project, role, canUpdate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.svelte index 27e2e8c..a795cac 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.svelte @@ -5,6 +5,8 @@ export let data: { project: { slug: string; organization: { slug: string } | null }; role: any; + canUpdate: boolean; + canDelete: boolean; }; $: baseHref = `/org/${data.project.organization?.slug ?? ''}/projects/${data.project.slug}/settings/roles-permissions`; @@ -15,4 +17,6 @@ role={data.role} title={`${$_('roleDetail.edit')} ${data.role.name}`} cancelHref={baseHref} + canUpdate={data.canUpdate} + canDelete={data.canDelete} /> diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts index a9ae860..6cab3f2 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService } from '$modules/auth'; import { projectService } from '$modules/projects'; @@ -29,9 +29,19 @@ async function canManageProjectRole( return { project, allowed }; } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { project } = await parent(); - return { project }; + const canCreate = await cancanService.canSessionUser(locals.user, 'project:roles:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canCreate) { + throw error(403, 'Forbidden'); + } + + return { project, canCreate }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.svelte index 06fee15..6d760cb 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.svelte @@ -2,9 +2,17 @@ import RoleDetail from '$lib/components/RoleDetail.svelte'; import { _ } from 'svelte-i18n'; - export let data: { project: { slug: string; organization: { slug: string } | null } }; + export let data: { + project: { slug: string; organization: { slug: string } | null }; + canCreate: boolean; + }; $: baseHref = `/org/${data.project.organization?.slug ?? ''}/projects/${data.project.slug}/settings/roles-permissions`; - + diff --git a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts index bbe4b0c..2eed6ca 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts @@ -42,14 +42,32 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } - const [apiKeys, roles] = await Promise.all([ + const [apiKeys, roles, canCreate, canUpdate, canDelete] = await Promise.all([ apiKeysService.listActiveApiKeysByProject(project.id), roleService.listRoles('project', project.id), + cancanService.canSessionUser(locals.user, 'project:server-keys:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:server-keys:update', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:server-keys:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), ]); return { apiKeys, roles: roles.map((role) => ({ id: role.id, name: role.name, slug: role.slug })), + canCreate, + canUpdate, + canDelete, }; } diff --git a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte index 85421b0..7ecd823 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte @@ -15,7 +15,13 @@ type RoleRow = { id: string; name: string; slug: string }; - export let data: { apiKeys: ApiKeyRow[]; roles: RoleRow[] }; + export let data: { + apiKeys: ApiKeyRow[]; + roles: RoleRow[]; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; + }; export let form: { success?: boolean; error?: string; createdKey?: string } | null; let createModalOpen = false; @@ -82,14 +88,16 @@ >.

- + {#if data.canCreate} + + {/if} @@ -163,28 +171,32 @@

- {#if !key.revokedAt} + {#if !key.revokedAt && (data.canUpdate || data.canDelete)}
- - - - -
- - - + {#if data.canUpdate} +
+ + + + {/if} + {#if data.canDelete} +
+ + + + {/if}
{/if} diff --git a/src/routes/org/[org]/projects/[slug]/state-iac/+page.server.ts b/src/routes/org/[org]/projects/[slug]/state-iac/+page.server.ts new file mode 100644 index 0000000..78a2855 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/state-iac/+page.server.ts @@ -0,0 +1,37 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; + +export async function load({ params, locals }) { + let project; + try { + project = await projectService.getProjectBySlug(params.slug); + } catch { + throw error(404, 'Project not found'); + } + + const organizationId = project.organization?.id; + const [canReadStacks, canReadStates, canReadHistory] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:stateiac:stacks:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + cancanService.canSessionUser(locals.user, 'project:stateiac:states:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + cancanService.canSessionUser(locals.user, 'project:stateiac:history:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + ]); + + if (!canReadStacks && !canReadStates && !canReadHistory) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/projects/[slug]/vault/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/+page.server.ts new file mode 100644 index 0000000..22b4373 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/+page.server.ts @@ -0,0 +1,32 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; + +export async function load({ params, locals }) { + let project; + try { + project = await projectService.getProjectBySlug(params.slug); + } catch { + throw error(404, 'Project not found'); + } + + const organizationId = project.organization?.id; + const [canReadSecrets, canReadEnvironments] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:vault:secrets:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + cancanService.canSessionUser(locals.user, 'project:vault:environments:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + ]); + + if (!canReadSecrets && !canReadEnvironments) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/settings/+layout.server.ts b/src/routes/org/[org]/settings/+layout.server.ts new file mode 100644 index 0000000..0c2bbec --- /dev/null +++ b/src/routes/org/[org]/settings/+layout.server.ts @@ -0,0 +1,15 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +// shell guard for the whole /settings subtree: CLAUDE.md's canManageOrganization gates this area. +// individual pages still re-check their own finer-grained organization:*:read permission. +export async function load({ parent, locals }) { + const { organization } = await parent(); + + const canManage = await cancanService.canManageOrganization(locals.user, organization.id); + if (!canManage) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/settings/access-control/+page.server.ts b/src/routes/org/[org]/settings/access-control/+page.server.ts index a78a974..8c915e3 100644 --- a/src/routes/org/[org]/settings/access-control/+page.server.ts +++ b/src/routes/org/[org]/settings/access-control/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService, userAccessService } from '$modules/auth'; import { organizationService } from '$modules/organization'; @@ -6,19 +6,46 @@ function errorResponse(error: unknown) { return fail(400, { error: error instanceof Error ? error.message : 'User action failed.' }); } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { organization } = await parent(); - const [users, roles] = await Promise.all([ + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + const [users, roles, canCreate, canUpdate, canDelete] = await Promise.all([ userAccessService.listUsers('organization', organization.id), roleService.listRoles('organization', organization.id), + cancanService.canSessionUser(locals.user, 'organization:users:create', { + scope: 'organization', + organizationId: organization.id, + }), + cancanService.canSessionUser(locals.user, 'organization:users:update', { + scope: 'organization', + organizationId: organization.id, + }), + cancanService.canSessionUser(locals.user, 'organization:users:delete', { + scope: 'organization', + organizationId: organization.id, + }), ]); - return { users, roles }; + return { users, roles, canCreate, canUpdate, canDelete }; } export const actions = { async addUser({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:create', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -39,7 +66,12 @@ export const actions = { async inviteUser({ request, locals, params, url }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:invite', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -60,7 +92,12 @@ export const actions = { async updateUserAccess({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:update', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -81,7 +118,12 @@ export const actions = { async removeUserAccess({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:delete', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -100,7 +142,12 @@ export const actions = { async resendInvitation({ request, locals, params, url }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:invite', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } diff --git a/src/routes/org/[org]/settings/access-control/+page.svelte b/src/routes/org/[org]/settings/access-control/+page.svelte index 208926d..f84f1a9 100644 --- a/src/routes/org/[org]/settings/access-control/+page.svelte +++ b/src/routes/org/[org]/settings/access-control/+page.svelte @@ -2,7 +2,13 @@ import Users from '$lib/components/Users.svelte'; import { _ } from 'svelte-i18n'; - export let data: { users: any[]; roles: any[] }; + export let data: { + users: any[]; + roles: any[]; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; + }; diff --git a/src/routes/org/[org]/settings/audit/+page.server.ts b/src/routes/org/[org]/settings/audit/+page.server.ts index d5686a2..5933cff 100644 --- a/src/routes/org/[org]/settings/audit/+page.server.ts +++ b/src/routes/org/[org]/settings/audit/+page.server.ts @@ -1,8 +1,20 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { auditService } from '$modules/audit'; +import { cancanService } from '$modules/auth'; +import { organizationService } from '$modules/organization'; -export async function load({ parent, url }) { +export async function load({ parent, url, locals }) { const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:audit:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + const { events, page, perPage, total, totalPages } = await auditService.listEvents({ organizationId: organization.id, search: url.searchParams.get('search') || undefined, @@ -20,7 +32,17 @@ export async function load({ parent, url }) { } export const actions = { - async viewDiff({ request }) { + async viewDiff({ request, locals, params }) { + const organization = await organizationService.findBySlug(params.org); + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:audit:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + return fail(403, { error: 'Forbidden' }); + } + const form = await request.formData(); const commit = String(form.get('commit') ?? ''); const entity = String(form.get('entity') ?? ''); diff --git a/src/routes/org/[org]/settings/global/+page.server.ts b/src/routes/org/[org]/settings/global/+page.server.ts new file mode 100644 index 0000000..393cccb --- /dev/null +++ b/src/routes/org/[org]/settings/global/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:settings:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/settings/projects/+page.server.ts b/src/routes/org/[org]/settings/projects/+page.server.ts index 6370604..e5ea6da 100644 --- a/src/routes/org/[org]/settings/projects/+page.server.ts +++ b/src/routes/org/[org]/settings/projects/+page.server.ts @@ -1,5 +1,5 @@ -import { fail } from '@sveltejs/kit'; -import { cancanService } from '$modules/auth'; +import { error, fail } from '@sveltejs/kit'; +import { cancanService, roleService } from '$modules/auth'; import { organizationService } from '$modules/organization'; import { projectService } from '$modules/projects'; @@ -7,10 +7,30 @@ function errorResponse(error: unknown) { return fail(400, { error: error instanceof Error ? error.message : 'Project action failed.' }); } -export async function load({ params }) { +export async function load({ params, locals }) { const organization = await organizationService.findBySlug(params.org); - const projects = await projectService.listProjectsByOrganization(organization.id); - return { organization, projects }; + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:projects:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + const [projects, canCreate, canDelete] = await Promise.all([ + projectService.listProjectsByOrganization(organization.id), + cancanService.canSessionUser(locals.user, 'organization:projects:create', { + scope: 'organization', + organizationId: organization.id, + }), + cancanService.canSessionUser(locals.user, 'organization:projects:delete', { + scope: 'organization', + organizationId: organization.id, + }), + ]); + return { organization, projects, canCreate, canDelete }; } export const actions = { @@ -36,6 +56,7 @@ export const actions = { description: String(form.get('description') ?? '') || undefined, status: String(form.get('status') ?? ''), }); + await roleService.createDefaultProjectRoles(project.id); return { success: true, project }; } catch (error: unknown) { @@ -48,11 +69,14 @@ export const actions = { const form = await request.formData(); const id = String(form.get('id') ?? ''); const project = await projectService.getProject(id); - const canDelete = await cancanService.canSessionUser(locals.user, 'project:project:delete', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }); + const canDelete = await cancanService.canSessionUser( + locals.user, + 'organization:projects:delete', + { + scope: 'organization', + organizationId: project.organization?.id ?? '', + }, + ); if (!canDelete) return fail(403, { error: 'Forbidden' }); diff --git a/src/routes/org/[org]/settings/projects/+page.svelte b/src/routes/org/[org]/settings/projects/+page.svelte index c1457a7..f550895 100644 --- a/src/routes/org/[org]/settings/projects/+page.svelte +++ b/src/routes/org/[org]/settings/projects/+page.svelte @@ -4,7 +4,12 @@ import { CheckCircle, Eye, FolderKanban, Plus, Search, Trash2 } from '@lucide/svelte'; import { _ } from 'svelte-i18n'; - export let data: { organization: { id: string; slug: string } | null; projects: ProjectRow[] }; + export let data: { + organization: { id: string; slug: string } | null; + projects: ProjectRow[]; + canCreate: boolean; + canDelete: boolean; + }; type ProjectRow = { id: string; @@ -155,12 +160,15 @@

{$_('orgSettings.projects.description')}

- + {#if data.canCreate} + + {/if}
@@ -204,7 +212,9 @@
- {projects.length === 0 ? $_('orgSettings.projects.empty') : $_('orgSettings.projects.emptyFiltered')} + {projects.length === 0 + ? $_('orgSettings.projects.empty') + : $_('orgSettings.projects.emptyFiltered')}
{:else}
@@ -256,14 +266,18 @@ class="btn-secondary inline-flex items-center gap-1.5 rounded-md px-2.5 py-2 text-xs font-medium" title={$_('orgSettings.projects.viewProject')} > - {$_('common.view')} - + {#if data.canDelete} + + {/if}
@@ -290,7 +304,9 @@ aria-label={$_('orgSettings.projects.createModal')} >
-
{$_('orgSettings.projects.createModalTitle')}
+
+ {$_('orgSettings.projects.createModalTitle')} +
@@ -331,15 +347,16 @@
- + + placeholder={$_('orgSettings.projects.descriptionPlaceholder')}>
@@ -395,7 +412,9 @@ aria-label={$_('orgSettings.projects.deleteModal')} >
-
{$_('orgSettings.projects.deleteConfirmTitle')}
+
+ {$_('orgSettings.projects.deleteConfirmTitle')} +
@@ -428,7 +447,9 @@ class="btn-danger inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium" > - {deleteLoading ? $_('orgSettings.projects.deleting') : $_('orgSettings.projects.deleteProject')} + {deleteLoading + ? $_('orgSettings.projects.deleting') + : $_('orgSettings.projects.deleteProject')}
diff --git a/src/routes/org/[org]/settings/roles-permissions/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/+page.server.ts index 3d5da10..2d88952 100644 --- a/src/routes/org/[org]/settings/roles-permissions/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService } from '$modules/auth'; import { organizationService } from '$modules/organization'; @@ -13,16 +13,37 @@ function errorResponse(error: unknown) { return fail(400, { error: error instanceof Error ? error.message : 'Role action failed.' }); } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { organization } = await parent(); - const roles = await roleService.listRoles('organization', organization.id); - return { roles }; + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + const [roles, canCreate] = await Promise.all([ + roleService.listRoles('organization', organization.id), + cancanService.canSessionUser(locals.user, 'organization:roles:create', { + scope: 'organization', + organizationId: organization.id, + }), + ]); + return { roles, canCreate }; } export const actions = { async createRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:create', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -43,7 +64,12 @@ export const actions = { async updateRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:update', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -61,7 +87,12 @@ export const actions = { async deleteRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:delete', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } diff --git a/src/routes/org/[org]/settings/roles-permissions/+page.svelte b/src/routes/org/[org]/settings/roles-permissions/+page.svelte index ad31f7b..ec94cad 100644 --- a/src/routes/org/[org]/settings/roles-permissions/+page.svelte +++ b/src/routes/org/[org]/settings/roles-permissions/+page.svelte @@ -2,7 +2,11 @@ import RolePermissions from '$lib/components/RolePermissions.svelte'; import { _ } from 'svelte-i18n'; - export let data: { organization: { id: string; slug: string }; roles: any[] }; + export let data: { + organization: { id: string; slug: string }; + roles: any[]; + canCreate: boolean; + }; diff --git a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts index e613552..940cbf9 100644 --- a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts @@ -15,18 +15,43 @@ function errorResponse(errorValue: unknown) { }); } -export async function load({ parent, params }) { +export async function load({ parent, params, locals }) { const { organization } = await parent(); - const roles = await roleService.listRoles('organization', organization.id); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + const [roles, canUpdate, canDelete] = await Promise.all([ + roleService.listRoles('organization', organization.id), + cancanService.canSessionUser(locals.user, 'organization:roles:update', { + scope: 'organization', + organizationId: organization.id, + }), + cancanService.canSessionUser(locals.user, 'organization:roles:delete', { + scope: 'organization', + organizationId: organization.id, + }), + ]); const role = roles.find((row) => row.id === params.id); if (!role) throw error(404, 'Role not found'); - return { organization, role }; + return { organization, role, canUpdate, canDelete }; } export const actions = { async updateRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:update', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -44,7 +69,12 @@ export const actions = { async deleteRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:delete', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } diff --git a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.svelte b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.svelte index 0fab8c8..5496f1b 100644 --- a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.svelte +++ b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.svelte @@ -2,7 +2,12 @@ import RoleDetail from '$lib/components/RoleDetail.svelte'; import { _ } from 'svelte-i18n'; - export let data: { organization: { slug: string }; role: any }; + export let data: { + organization: { slug: string }; + role: any; + canUpdate: boolean; + canDelete: boolean; + }; diff --git a/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts index 6dd471b..dc8e365 100644 --- a/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService } from '$modules/auth'; import { organizationService } from '$modules/organization'; @@ -15,15 +15,29 @@ function errorResponse(errorValue: unknown) { }); } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { organization } = await parent(); - return { organization }; + const canCreate = await cancanService.canSessionUser(locals.user, 'organization:roles:create', { + scope: 'organization', + organizationId: organization.id, + }); + + if (!canCreate) { + throw error(403, 'Forbidden'); + } + + return { organization, canCreate }; } export const actions = { async createRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:create', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } diff --git a/src/routes/org/[org]/settings/roles-permissions/new/+page.svelte b/src/routes/org/[org]/settings/roles-permissions/new/+page.svelte index 66335a1..c030a6a 100644 --- a/src/routes/org/[org]/settings/roles-permissions/new/+page.svelte +++ b/src/routes/org/[org]/settings/roles-permissions/new/+page.svelte @@ -2,11 +2,12 @@ import RoleDetail from '$lib/components/RoleDetail.svelte'; import { _ } from 'svelte-i18n'; - export let data: { organization: { slug: string } }; + export let data: { organization: { slug: string }; canCreate: boolean }; diff --git a/src/routes/org/[org]/settings/server-access-keys/+page.server.ts b/src/routes/org/[org]/settings/server-access-keys/+page.server.ts new file mode 100644 index 0000000..95f1dc5 --- /dev/null +++ b/src/routes/org/[org]/settings/server-access-keys/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:server-keys:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/settings/system-backup/+page.server.ts b/src/routes/org/[org]/settings/system-backup/+page.server.ts new file mode 100644 index 0000000..13123cd --- /dev/null +++ b/src/routes/org/[org]/settings/system-backup/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:backups:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/tsconfig.json b/tsconfig.json index 5b531fb..c122e1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,13 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "paths": { + "$modules": ["./src/modules"], + "$modules/*": ["./src/modules/*"], + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"], + "$env/dynamic/private": ["./test/env-dynamic-private.ts"] + } } }
{$_('common.name')} {$_('common.slug')}
- + {role.name} diff --git a/src/lib/components/Users.svelte b/src/lib/components/Users.svelte index f157537..941eef1 100644 --- a/src/lib/components/Users.svelte +++ b/src/lib/components/Users.svelte @@ -46,6 +46,9 @@ export let assignableUsers: AssignableUserRow[] = []; export let title = ''; export let description = ''; + export let canCreate = true; + export let canUpdate = false; + export let canDelete = true; let users: AccessUserRow[] = initialUsers; let searchQuery = ''; @@ -122,7 +125,9 @@ const result = deserialize(await response.text()); if (result.type === 'failure' || result.type === 'error') { const data = result.type === 'failure' ? result.data : null; - throw new Error(typeof data?.error === 'string' ? data.error : $_('usersComponent.actionFailed')); + throw new Error( + typeof data?.error === 'string' ? data.error : $_('usersComponent.actionFailed'), + ); } await invalidateAll(); } @@ -156,7 +161,9 @@ userId: selectedUserId, }); addModalOpen = false; - flashSuccess(scope === 'project' ? $_('usersComponent.userAssigned') : $_('usersComponent.userCreated')); + flashSuccess( + scope === 'project' ? $_('usersComponent.userAssigned') : $_('usersComponent.userCreated'), + ); } catch (err: unknown) { addError = err instanceof Error ? err.message : $_('usersComponent.addFailed'); } finally { @@ -274,29 +281,35 @@
-

{title || $_('usersComponent.defaultTitle')}

-

{description || $_('usersComponent.defaultDescription')}

+

+ {title || $_('usersComponent.defaultTitle')} +

+

+ {description || $_('usersComponent.defaultDescription')} +

-
- {#if scope === 'organization'} + {#if canCreate} +
+ {#if scope === 'organization'} + + {/if} - {/if} - -
+
+ {/if}
@@ -385,39 +398,45 @@
-
- - {#if openRoleMenuId === user.id} -
+ - {/each} -
- {/if} -
+ {user.role?.name ?? $_('usersComponent.selectRole')} + + + {#if openRoleMenuId === user.id} +
+ {#each roles as role (role.id)} + + {/each} +
+ {/if} + + {:else} + {user.role?.name ?? $_('usersComponent.selectRole')} + {/if}
{formatDate(user.createdAt)}
- {#if user.status === 'invited'} + {#if user.status === 'invited' && canCreate} + {/if} + {#if canDelete} + {/if} -