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
7 changes: 7 additions & 0 deletions .rooignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
dist/
build/
.git/
*.log
package-lock.json
pnpm-lock.yaml
27 changes: 21 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,34 @@ architecture discussion and migration plan.

## Authorization

Permissions use `section:action` and may be global, organization-scoped, or project-scoped. Use
helpers exported by `$modules/auth`:
Permission grants are always scope-prefixed and match the catalog in `src/lib/config/permissions.ts`
exactly: `<scope>:<resource path>:<action>`, where scope is `cluster`, `organization` or `project`
(`project:vault:secrets:read`, `organization:projects:create`, `cluster:users:invite`). A
`<resource>:all` grant covers every action on that resource. Grants stored before this convention
are upgraded on read by `normalizePermissionGrant` in `$lib/permissions`.

Use the helpers exported by `$modules/auth`:

```typescript
import { can } from '$modules/auth';
import { cancanService } from '$modules/auth';

const allowed = await cancanService.canSessionUser(locals.user, 'project:stateiac:stacks:read', {
scope: 'project',
projectId: project.id,
organizationId: project.organization?.id,
});

if (!can(locals.user, 'stateiac:read')) {
if (!allowed) {
return json({ error: 'Forbidden' }, { status: 403 });
}
```

`locals.user.role` is a session role object, not the string `admin`. Use `isAdmin()` or `can()`.
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.

`locals.user.role` is a session role object, not the string `admin`. Use `isAdmin()` or the `can*`
helpers. Keep authorization tests beside changes to permission behavior.

## Security rules

Expand Down
101 changes: 101 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { AuthenticatedUser } from '$modules/auth/domain/entities';
import type { AuthenticatedApiKey, AuthenticatedUser } from '$modules/auth/domain/entities';

declare global {
namespace App {
interface Locals {
user?: AuthenticatedUser | null;
apiKey?: AuthenticatedApiKey | null;
}
}
}
Expand Down
46 changes: 34 additions & 12 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Handle } from '@sveltejs/kit';
import { authService, cancanService, ensureAuthReady } from '$modules/auth';
import { apiKeysService, authService, cancanService, ensureAuthReady } from '$modules/auth';
import { organizationService } from '$modules/organization';
import { projectService } from '$modules/projects';
import { isBootstrapCompleted, refreshBootstrapState } from '$lib/server/bootstrap';
Expand All @@ -20,9 +20,18 @@ serverReady.catch((error) => {
markServerFailed(error);
});

const authWithToken = async (_token: string) => {
return true;
};
function bearerToken(request: Request): string | null {
const header = request.headers.get('Authorization') ?? '';
const match = header.match(/^Bearer\s+(.+)$/i);
return match ? match[1].trim() || null : null;
}

function unauthorized(message: string) {
return new Response(JSON.stringify({ error: message }), {
status: 401,
headers: { 'content-type': 'application/json' },
});
}

export const handle: Handle = async ({ event, resolve }) => {
const pathname = event.url.pathname;
Expand Down Expand Up @@ -68,18 +77,28 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}

if (isApiRequest) {
const token = event.request.headers.get('Authorization') || '';
if (!token || token.trim() === '') {
return new Response(null, { status: 401 });
// machine-to-machine path: `Authorization: Bearer gvs_...` resolves a project + role identity.
// it coexists with the session cookie path below, which still serves browser requests to /api.
const token = bearerToken(event.request);

if (token) {
// a project key is confined to its own project and must never reach the admin/global UI areas
if (!isApiRequest) {
return unauthorized('API keys can only be used on /api routes');
}

const isAuthenticated = await authWithToken(token);
const apiKey = await apiKeysService.authenticate(token);

if (!isAuthenticated) {
return new Response(null, { status: 401 });
if (!apiKey) {
return unauthorized('Invalid API key');
}
return runWithActor({ name: 'apikey', email: 'apikey@gitops.local' }, () => resolve(event));

event.locals.apiKey = apiKey;

return runWithActor(
{ name: `apikey:${apiKey.name}`, email: `apikey+${apiKey.id}@gitops.local` },
() => resolve(event),
);
}

const sessionCookie = event.cookies.get('pos_session');
Expand All @@ -90,6 +109,9 @@ export const handle: Handle = async ({ event, resolve }) => {
}

if (!currentUser) {
if (isApiRequest) {
return unauthorized('Authentication required');
}
return new Response(null, { status: 302, headers: { location: '/auth/login' } });
}

Expand Down
Loading
Loading