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
689 changes: 687 additions & 2 deletions agents-manage-api/__snapshots__/openapi.json

Large diffs are not rendered by default.

225 changes: 225 additions & 0 deletions agents-manage-api/src/__tests__/middleware/project-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { Hono } from 'hono';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { requireProjectPermission } from '../../middleware/project-access';
import type { BaseAppVariables } from '../../types/app';

// Mock the authz module
vi.mock('@inkeep/agents-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@inkeep/agents-core')>();
return {
...actual,
isAuthzEnabled: vi.fn(() => false),
canViewProject: vi.fn(() => Promise.resolve(true)),
canUseProject: vi.fn(() => Promise.resolve(true)),
canEditProject: vi.fn(() => Promise.resolve(false)),
createApiError: actual.createApiError,
};
});

// Import mocked functions
import { canEditProject, canUseProject, canViewProject, isAuthzEnabled } from '@inkeep/agents-core';

describe('requireProjectPermission middleware', () => {
let app: Hono<{ Variables: BaseAppVariables }>;

beforeEach(() => {
vi.clearAllMocks();
process.env.DISABLE_AUTH = 'false';
process.env.ENVIRONMENT = 'development';

app = new Hono<{ Variables: BaseAppVariables }>();

// Set up context variables
app.use('*', async (c, next) => {
c.set('userId', 'test-user');
c.set('tenantId', 'test-tenant');
c.set('tenantRole', 'member');
c.set('auth', null);
c.set('userEmail', '[email protected]');
await next();
});
});

afterEach(() => {
delete process.env.DISABLE_AUTH;
delete process.env.ENVIRONMENT;
});

describe('when DISABLE_AUTH is true', () => {
it('should skip checks and allow access', async () => {
process.env.DISABLE_AUTH = 'true';

app.use('/projects/:projectId', requireProjectPermission('view'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });
});
});

describe('when in test environment', () => {
it('should skip checks and allow access', async () => {
process.env.ENVIRONMENT = 'test';

app.use('/projects/:projectId', requireProjectPermission('view'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(200);
});
});

describe('for system users', () => {
it('should bypass project access checks', async () => {
const systemApp = new Hono<{ Variables: BaseAppVariables }>();
systemApp.use('*', async (c, next) => {
c.set('userId', 'system');
c.set('tenantId', 'test-tenant');
c.set('tenantRole', 'owner');
c.set('auth', null);
c.set('userEmail', '[email protected]');
await next();
});
systemApp.use('/projects/:projectId', requireProjectPermission('edit'));
systemApp.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await systemApp.request('/projects/test-project');
expect(res.status).toBe(200);
});
});

describe('for API key users', () => {
it('should bypass project access checks', async () => {
const apiKeyApp = new Hono<{ Variables: BaseAppVariables }>();
apiKeyApp.use('*', async (c, next) => {
c.set('userId', 'apikey:abc123');
c.set('tenantId', 'test-tenant');
c.set('tenantRole', 'owner');
c.set('auth', null);
c.set('userEmail', '');
await next();
});
apiKeyApp.use('/projects/:projectId', requireProjectPermission('edit'));
apiKeyApp.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await apiKeyApp.request('/projects/test-project');
expect(res.status).toBe(200);
});
});

describe('view permission', () => {
it('should allow access when canViewProject returns true', async () => {
vi.mocked(canViewProject).mockResolvedValue(true);

app.use('/projects/:projectId', requireProjectPermission('view'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(200);
expect(canViewProject).toHaveBeenCalledWith({
tenantId: 'test-tenant',
userId: 'test-user',
projectId: 'test-project',
orgRole: 'member',
});
});

it('should deny access when canViewProject returns false (authz disabled)', async () => {
vi.mocked(canViewProject).mockResolvedValue(false);
vi.mocked(isAuthzEnabled).mockReturnValue(false);

app.use('/projects/:projectId', requireProjectPermission('view'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(403);
});

it('should return 404 when canViewProject returns false (authz enabled)', async () => {
vi.mocked(canViewProject).mockResolvedValue(false);
vi.mocked(isAuthzEnabled).mockReturnValue(true);

app.use('/projects/:projectId', requireProjectPermission('view'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(404);
});
});

describe('use permission', () => {
it('should allow access when canUseProject returns true', async () => {
vi.mocked(canUseProject).mockResolvedValue(true);

app.use('/projects/:projectId', requireProjectPermission('use'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(200);
expect(canUseProject).toHaveBeenCalled();
});

it('should deny access when canUseProject returns false', async () => {
vi.mocked(canUseProject).mockResolvedValue(false);
vi.mocked(isAuthzEnabled).mockReturnValue(false);

app.use('/projects/:projectId', requireProjectPermission('use'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(403);
});
});

describe('edit permission', () => {
it('should allow access when canEditProject returns true', async () => {
vi.mocked(canEditProject).mockResolvedValue(true);

app.use('/projects/:projectId', requireProjectPermission('edit'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(200);
expect(canEditProject).toHaveBeenCalled();
});

it('should deny access when canEditProject returns false', async () => {
vi.mocked(canEditProject).mockResolvedValue(false);
vi.mocked(isAuthzEnabled).mockReturnValue(false);

app.use('/projects/:projectId', requireProjectPermission('edit'));
app.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await app.request('/projects/test-project');
expect(res.status).toBe(403);
});
});

describe('error handling', () => {
it('should return 401 when userId is missing', async () => {
const noUserApp = new Hono<{ Variables: BaseAppVariables }>();
noUserApp.use('*', async (c, next) => {
c.set('userId', '');
c.set('tenantId', 'test-tenant');
c.set('tenantRole', 'member');
c.set('auth', null);
c.set('userEmail', '');
await next();
});
noUserApp.use('/projects/:projectId', requireProjectPermission('view'));
noUserApp.get('/projects/:projectId', (c) => c.json({ success: true }));

const res = await noUserApp.request('/projects/test-project');
expect(res.status).toBe(401);
});

it('should return 400 when projectId is missing', async () => {
app.use('/no-project-id', requireProjectPermission('view'));
app.get('/no-project-id', (c) => c.json({ success: true }));

const res = await app.request('/no-project-id');
expect(res.status).toBe(400);
});
});
});
82 changes: 44 additions & 38 deletions agents-manage-api/src/__tests__/rbac-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,70 @@
import { ac } from '@inkeep/agents-core/auth/permissions';
import { ac, adminRole, memberRole, ownerRole } from '@inkeep/agents-core/auth/permissions';
import { describe, expect, it } from 'vitest';

/**
* RBAC Permission Tests
*
* Better Auth RBAC is used for organization-level roles and permissions.
* Project-level resource permissions (agents, tools, etc.) are handled by SpiceDB.
*
* Architecture:
* - Better Auth: org roles (owner, admin, member) + org resources (organization, member, invitation, team)
* - SpiceDB: project-level permissions (view, use, edit, delete) for project resources
*/
describe('RBAC Permission Definitions', () => {
describe('Access Control Statements', () => {
it('should define all required resource permissions', () => {
it('should define project resource with full CRUD', () => {
const statements = ac.statements;

// Verify all custom resources have full CRUD
// Project is the only custom resource in Better Auth
// Actual project permissions are checked via SpiceDB
expect(statements.project).toEqual(['create', 'read', 'update', 'delete']);
expect(statements.agent).toEqual(['create', 'read', 'update', 'delete']);
expect(statements.tool).toEqual(['create', 'read', 'update', 'delete']);
expect(statements.api_key).toEqual(['create', 'read', 'update', 'delete']);
expect(statements.credential).toEqual(['create', 'read', 'update', 'delete']);
expect(statements.data_component).toEqual(['create', 'read', 'update', 'delete']);
expect(statements.artifact_component).toEqual(['create', 'read', 'update', 'delete']);
expect(statements.external_agent).toEqual(['create', 'read', 'update', 'delete']);
expect(statements.function).toEqual(['create', 'read', 'update', 'delete']);
});

it('should include Better Auth default organization statements', () => {
const statements = ac.statements;

// Default org management permissions (no "read" by design)
// Default org management permissions from Better Auth
expect(statements.organization).toEqual(['update', 'delete']);
expect(statements.member).toEqual(['create', 'update', 'delete']);
expect(statements.invitation).toEqual(['create', 'cancel']);
expect(statements.team).toEqual(['create', 'update', 'delete']);
});
});

it('should define access control resource with full CRUD', () => {
const statements = ac.statements;
describe('Organization Roles', () => {
it('should define owner role with full project permissions', () => {
expect(ownerRole.statements.project).toEqual(['create', 'read', 'update', 'delete']);
});

// The "ac" resource has full CRUD (used for permission management)
expect(statements.ac).toEqual(['create', 'read', 'update', 'delete']);
it('should define admin role with full project permissions', () => {
expect(adminRole.statements.project).toEqual(['create', 'read', 'update', 'delete']);
});

it('should define member role with read-only project permissions', () => {
// Members only have read access at the org level
// Project-level permissions are granted via SpiceDB project roles
expect(memberRole.statements.project).toEqual(['read']);
});
});

describe('Statement Structure', () => {
it('should ensure all resources have consistent permission sets', () => {
const resources = [
'project',
'agent',
'tool',
'api_key',
'credential',
'data_component',
'artifact_component',
'external_agent',
'function',
] as const;
describe('SpiceDB Integration Notes', () => {
it('should document that project-level resources use SpiceDB', () => {
// This test documents the architecture decision
// Project-level resources (agents, tools, api_keys, etc.) are NOT in Better Auth
// They are protected by SpiceDB permissions: view, use, edit, delete

const statements = ac.statements;

for (const resource of resources) {
const permissions = ac.statements[resource];
expect(permissions).toBeDefined();
expect(permissions).toContain('create');
expect(permissions).toContain('read');
expect(permissions).toContain('update');
expect(permissions).toContain('delete');
expect(permissions.length).toBe(4);
}
// These resources are NOT defined in Better Auth (handled by SpiceDB)
expect(statements.agent).toBeUndefined();
expect(statements.tool).toBeUndefined();
expect(statements.api_key).toBeUndefined();
expect(statements.credential).toBeUndefined();
expect(statements.data_component).toBeUndefined();
expect(statements.artifact_component).toBeUndefined();
expect(statements.external_agent).toBeUndefined();
expect(statements.function).toBeUndefined();
});
});
});
4 changes: 4 additions & 0 deletions agents-manage-api/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { afterAll, afterEach, beforeAll } from 'vitest';
import manageDbClient from '../data/db/dbClient';
import runDbClient from '../data/db/runDbClient';

// Disable SpiceDB authorization for tests (unless explicitly testing authz)
// This ensures tests run without requiring a SpiceDB instance
process.env.ENABLE_AUTHZ = 'false';

// Initialize database schema for in-memory test databases using Drizzle migrations
beforeAll(async () => {
const logger = getLogger('Test Setup');
Expand Down
Loading
Loading