Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Package manager: **Bun** (`bun.lock`). Usar `bun`, nunca `npm`/`yarn`.

### Módulos (DDD ligero)

La lógica de negocio vive en `src/modules/<nombre>/` (`auth`, `config`, `projects`, `storage`), cada uno separado en:
La lógica de negocio vive en `src/modules/<nombre>/` (`auth`, `config`, `organization`, `projects`, `storage`), cada uno separado en:

- `domain/` — entidades (`entities.ts`), interfaces de repositorio (`repositories.ts`), clases de dominio (`*.domain.ts`)
- `application/` — servicios que orquestan el dominio (`*.service.ts`) — esto es lo que llaman las rutas
Expand All @@ -53,11 +53,22 @@ Env vars (`.env.example`): `GITDB_REPOSITORY_URL` (obligatoria), `GITDB_ENCRYPTI

### Puerta de acceso global

`src/hooks.server.ts`: inicializa gitdb, resuelve usuario desde la cookie `pos_session`, redirige a `/login` si no hay sesión, y restringe `/settings/*` + `/api/system/*` a admins.
`src/hooks.server.ts`: inicializa gitdb, resuelve usuario desde la cookie `pos_session`, redirige a `/login` si no hay sesión, valida permisos de proyecto/organización en rutas `/org/*/projects/*/settings` y `/org/*/settings`, y restringe `/settings/*` + `/api/system/*` a admins.

## RBAC / Permisos

Permisos = strings `section:action` (`vault|openreport|stateiac` : `read|create|update|delete`) o el atajo `section:all`, definidos en `src/lib/permissions/index.ts`. Roles en gitdb (`.gitdb/roles.json`), gestionados por `roleService`.
Permisos = strings con formato global (`vault:read`), organización (`organization:projects:all`) o proyecto (`project:vault:secrets:read`), definidos en `src/lib/permissions/index.ts`. Roles en gitdb (`.gitdb/roles.json`), gestionados por `roleService`.

**Roles de organización** (`scope: 'organization'`) se crean automáticamente al crear organización (`createDefaultOrganizationRoles`):
- `org-admin`: permisos completos (`organization:projects:all`, `organization:users:all`, etc.)
- `org-developer`: lectura/creación/actualización de proyectos (`organization:projects:read|create|update`)

**Roles de proyecto** (`scope: 'project'`) se crean automáticamente al crear proyecto (`createDefaultProjectRoles`):
- `project-admin`: permisos completos a nivel de proyecto
- `project-developer`: lectura/creación/actualización de vault/state/code report (sin delete ni gestión de roles)
- `project-viewer`: solo lectura en todos los recursos del proyecto

**Control de acceso a organizaciones**: `cancanService.organizationIdsForUser(user)` devuelve los IDs de organizaciones a las que el usuario puede acceder (directo o a través de proyectos), o `null` para admins sin restricción. Se usa en rutas como `/org` para filtrar contenido.

```typescript
// ✅ patrón correcto en un endpoint (api/roles, api/projects, api/backends)
Expand Down
24 changes: 19 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ La lógica de negocio vive en `src/modules/<nombre>/`, cada uno separado en:
| `infrastructure/repositories/` | Implementaciones concretas de repositorio |
| `index.ts` | **Composition root** del módulo: instancia repositorios + servicios y exporta singletons |

Módulos existentes: `auth`, `config`, `projects`, `storage`.
Módulos existentes: `auth`, `config`, `organization`, `projects`, `storage`.

**Regla de acceso**: las rutas y otros módulos importan únicamente desde el `index.ts` de cada módulo (p. ej. `import { can, isAdmin, roleService } from '../../modules/auth'`), nunca de `application/`, `domain/` o `infrastructure/` directamente.

Expand Down Expand Up @@ -79,19 +79,33 @@ Variables de entorno (`.env.example`): `GITDB_REPOSITORY_URL` (obligatoria, lanz
1. Inicializa gitdb (`getGitDb()` a nivel de módulo, una sola vez).
2. Deja pasar `/login` y `/api/auth/*` sin sesión.
3. Resuelve el usuario desde la cookie `pos_session` (`authService.resolveAuthenticatedUser`); si no hay usuario válido, redirige (302) a `/login`.
4. Restringe `/settings/*` y `/api/system/*` a admins (`canAccessAdminArea`).
4. Valida permisos de proyecto para rutas `/org/[org]/projects/[slug]/settings` (`canManageProject`); si no tiene acceso, redirige a `/`.
5. Restringe `/org/[org]/settings` a usuarios con permiso de organización (`canManageOrganization`); si no, redirige a `/`.
6. Restringe `/settings/*` y `/api/system/*` a admins (`canAccessAdminArea`).

---

## RBAC / Permisos

Los permisos son strings `section:action` (p. ej. `vault:read`) o el atajo `section:all`, definidos en `src/lib/permissions/index.ts`:
Los permisos son strings `section:action` (p. ej. `vault:read`) o el atajo `section:all`, definidos en `src/lib/permissions/index.ts`. El formato soporta dos niveles de scope:

- `section` ∈ `vault | openreport | stateiac`
- `action` ∈ `read | create | update | delete`
- **Global**: `section:action` donde `section` ∈ `vault | openreport | stateiac`
- **Organización**: `organization:action` (p. ej. `organization:projects:read`, `organization:users:all`)
- **Proyecto**: `project:action` (p. ej. `project:vault:secrets:read`, `project:stateiac:stacks:all`)

Roles a nivel de organización (`scope: 'organization'`) se crean automáticamente al crear una organización (método `RoleService.createDefaultOrganizationRoles`):
- `org-admin`: permisos completos a nivel de organización (`organization:projects:all`, `organization:users:all`, `organization:roles:all`, etc.)
- `org-developer`: lectura, creación y actualización de proyectos (`organization:projects:read|create|update`)

Roles a nivel de proyecto (`scope: 'project'`) se crean automáticamente al crear un proyecto (método `RoleService.createDefaultProjectRoles`):
- `project-admin`: permisos completos a nivel de proyecto (vault, state/IaC, code report, usuarios, roles, auditoría)
- `project-developer`: permisos de lectura, creación y actualización para vault/state/code report (sin permisos de eliminación ni gestión de roles)
- `project-viewer`: permisos de lectura exclusivamente en todos los recursos del proyecto

Los roles (y su array de `permissions`) viven en gitdb (`.gitdb/roles.json`), gestionados por `roleService`. El rol `admin` es especial: no se puede borrar (`RoleService.deleteRole` lo bloquea explícitamente) y `isAdmin(user)` comprueba `user.role.slug === 'admin'`.

**Control de acceso a organizaciones**: `CanCanService.organizationIdsForUser(user)` devuelve los IDs de organizaciones a las que un usuario tiene acceso — directo (vía rol de organización) o indirecto (vía rol en un proyecto). Devuelve `null` para admins de cluster (sin restricción). Se usa en rutas como `/org` para filtrar qué organizaciones ve el usuario.

**Patrón correcto en un endpoint** (todas las rutas bajo `api/roles`, `api/projects`, `api/backends` lo siguen):

```typescript
Expand Down
15 changes: 14 additions & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Handle } from '@sveltejs/kit';
import { authService, cancanService, ensureAuthReady } from './modules/auth';
import { ensureOrganizationReady, organizationService } from './modules/organization';
import { projectService } from './modules/projects';
import { getGitDb } from '$lib/server/gitdb';

getGitDb();
Expand Down Expand Up @@ -43,8 +44,20 @@ export const handle: Handle = async ({ event, resolve }) => {
return new Response(null, { status: 302, headers: { location: '/login' } });
}

const projectSettingsMatch = event.url.pathname.match(
/^\/org\/[^/]+\/projects\/([^/]+)\/settings/,
);
const organizationSettingsMatch = event.url.pathname.match(/^\/org\/([^/]+)\/settings/);
if (organizationSettingsMatch) {

if (projectSettingsMatch) {
const project = await projectService.tryFindBySlug(projectSettingsMatch[1]);
if (
!project ||
!(await cancanService.canManageProject(currentUser, project.id, project.organization?.id))
) {
return new Response(null, { status: 302, headers: { location: '/' } });
}
} else if (organizationSettingsMatch) {
const organization = await organizationService.tryFindBySlug(organizationSettingsMatch[1]);
if (
!organization ||
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/AppNavbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@
<Building2 class="h-4 w-4" />
Change Organization
</a>
<form action="/api/auth/logout" method="POST" on:submit={closeUserDropdown}>
<form action="/api/auth/logout" method="POST">
<button
type="submit"
class="btn-ghost flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium text-red-600 hover:bg-red-50"
Expand Down
40 changes: 23 additions & 17 deletions src/lib/components/AppSidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
Users,
Layers,
HardDrive,
Bot,
ShieldAlert,
} from 'lucide-svelte';

Expand All @@ -34,6 +33,9 @@
export let collapsed = true;
export let organizationSlug: string | null = null;
export let organizationName: string | null = null;
export let canAccessClusterSettings = false;
export let canManageOrganization = false;
export let canManageProject = false;
export let currentProjectSlug: string | null = null;
export let projects: {
slug: string;
Expand Down Expand Up @@ -151,7 +153,7 @@
},
]
: []),
...(currentProjectSlug
...(currentProjectSlug && canManageProject
? [
{
name: 'Proyecto',
Expand Down Expand Up @@ -186,7 +188,7 @@
{
name: 'Sistema',
modules: [
...(organizationSlug
...(organizationSlug && canManageOrganization
? [
{
name: 'Organization Settings',
Expand Down Expand Up @@ -226,22 +228,26 @@
},
]
: []),
{
name: 'Cluster Settings',
icon: Building2,
items: [
{ label: 'Organizations', href: '/cluster-settings/orgs', icon: Building2 },
{
label: 'Roles & Permissions',
href: '/cluster-settings/roles-permissions',
icon: Shield,
},
{ label: 'Users', href: '/cluster-settings/users', icon: Users },
],
},
...(canAccessClusterSettings
? [
{
name: 'Cluster Settings',
icon: Building2,
items: [
{ label: 'Organizations', href: '/cluster-settings/orgs', icon: Building2 },
{
label: 'Roles & Permissions',
href: '/cluster-settings/roles-permissions',
icon: Shield,
},
{ label: 'Users', href: '/cluster-settings/users', icon: Users },
],
},
]
: []),
],
},
] satisfies NavCategory[];
].filter((category) => category.modules.length > 0) satisfies NavCategory[];

function isItemActive(href: string) {
return currentPath.startsWith(href);
Expand Down
59 changes: 59 additions & 0 deletions src/modules/auth/application/cancan.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function access(input: {
scope: 'organization' | 'project';
organizationId?: string;
projectId?: string;
project?: { id: string; organizationId: string };
}) {
return new UserAccessDomain({
id: input.id,
Expand All @@ -44,6 +45,14 @@ function access(input: {
scope: input.scope,
organizationId: input.organizationId,
projectId: input.projectId,
project: input.project
? {
id: input.project.id,
name: input.project.id,
slug: input.project.id,
organization: { id: input.project.organizationId, name: '', slug: '' },
}
: undefined,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
});
Expand Down Expand Up @@ -192,4 +201,54 @@ describe('CanCanService', () => {
).resolves.toBe(false);
await expect(service.can('jose', 'stateiac:read', { scope: 'cluster' })).resolves.toBe(true);
});

describe('organizationIdsForUser', () => {
it('returns null for a cluster admin (no restriction)', async () => {
const jose = user({ id: 'jose', role: role({ id: 'admin', slug: 'admin' }) });
await expect(service.organizationIdsForUser(jose)).resolves.toBeNull();
});

it('returns an empty list for a user with no access rows', async () => {
const jose = user({ id: 'jose', role: null });
await expect(service.organizationIdsForUser(jose)).resolves.toEqual([]);
});

it('collects organizations from direct organization access', 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.organizationIdsForUser(jose)).resolves.toEqual(['gitops']);
});

it('collects the parent organization from project access, deduplicated', 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-admin', slug: 'project-admin', scope: 'project' }),
}),
access({
id: 'access-2',
userId: 'jose',
scope: 'organization',
organizationId: 'gitops',
role: role({ id: 'org-developer', slug: 'org-developer', scope: 'organization' }),
}),
);

await expect(service.organizationIdsForUser(jose)).resolves.toEqual(['gitops']);
});
});
});
44 changes: 38 additions & 6 deletions src/modules/auth/application/cancan.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Permission } from '$lib/permissions';
import type { PermissionGrant } from '$lib/permissions';
import type { ProjectDomain } from '../../projects/domain/project.domain';
import type { RoleDomain } from '../domain/role.domain';
import type { UserDomain } from '../domain/user.domain';
Expand Down Expand Up @@ -43,7 +43,7 @@ export class CanCanService {
private readonly projectLookup?: ProjectLookup,
) {}

async can(userId: string, permission: Permission, context: CanCanContext): Promise<boolean> {
async can(userId: string, permission: PermissionGrant, context: CanCanContext): Promise<boolean> {
const user = await this.userRepository.findById(userId);
if (!user) return false;

Expand All @@ -52,7 +52,7 @@ export class CanCanService {

async canSessionUser(
user: PermissionAwareUser,
permission: Permission,
permission: PermissionGrant,
context: CanCanContext,
): Promise<boolean> {
if (!user?.id) return false;
Expand All @@ -70,6 +70,38 @@ export class CanCanService {
return this.canSessionUser(user, 'stateiac:read', { scope: 'organization', organizationId });
}

async canManageProject(
user: PermissionAwareUser,
projectId: string,
organizationId?: string,
): Promise<boolean> {
return this.canSessionUser(user, 'project:project:read', {
scope: 'project',
projectId,
organizationId,
});
}

// organizationIds this user is a member of, directly or via a project under that organization.
// returns null for a cluster admin, meaning "no restriction, sees every organization".
async organizationIdsForUser(user: PermissionAwareUser): Promise<string[] | null> {
if (!user?.id) return [];
if (this.isClusterAdmin(user.role ?? null)) return null;

const access = await this.userAccessRepository.findByUserId(user.id);
const organizationIds = new Set<string>();

for (const entry of access) {
if (entry.scope === 'organization' && entry.organizationId) {
organizationIds.add(entry.organizationId);
} else if (entry.scope === 'project' && entry.project?.organization?.id) {
organizationIds.add(entry.project.organization.id);
}
}

return Array.from(organizationIds);
}

canAccessAdminArea(user: PermissionAwareUser): boolean {
return this.isAdmin(user);
}
Expand All @@ -80,7 +112,7 @@ export class CanCanService {

async canForUser(
user: UserDomain,
permission: Permission,
permission: PermissionGrant,
context: CanCanContext,
): Promise<boolean> {
if (this.isClusterAdmin(user.role)) return true;
Expand Down Expand Up @@ -123,7 +155,7 @@ export class CanCanService {
.filter((role): role is RoleDomain => Boolean(role));
}

private roleCan(role: PermissionRole, permission: Permission): boolean {
private roleCan(role: PermissionRole, permission: PermissionGrant): boolean {
if (!role) return false;
if (this.isAdminRole(role)) return true;
return CanCanService.hasPermission(role.permissions, permission);
Expand All @@ -140,7 +172,7 @@ export class CanCanService {

static hasPermission(
grants: readonly string[] | null | undefined,
permission: Permission,
permission: PermissionGrant,
): boolean {
if (!grants || grants.length === 0) return false;

Expand Down
Loading
Loading