Skip to content
Open
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
1,491 changes: 1,491 additions & 0 deletions BACKEND_ARCHITECTURE_EXPLORATION.md

Large diffs are not rendered by default.

735 changes: 735 additions & 0 deletions MULTI_TENANT_TESTING_GUIDE.md

Large diffs are not rendered by default.

109 changes: 108 additions & 1 deletion app/backend/src/api-keys/api-keys.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@
ParseUUIDPipe,
Post,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiQuery, ApiResponse, ApiTags, ApiHeader } from '@nestjs/swagger';
import { ApiKeysService } from './api-keys.service';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { CursorPaginationQueryDto } from '../dto/pagination/pagination.dto';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
import { OrganizationAccessGuard } from '../auth/guards/organization-access.guard';
import { RoleGuard } from '../auth/guards/role.guard';
import { RequireRole } from '../auth/decorators/require-role.decorator';

@ApiTags('api-keys')
@Controller('api-keys')
Expand Down Expand Up @@ -70,4 +76,105 @@
rotate(@Param('id', ParseUUIDPipe) id: string) {
return this.service.rotate(id);
}

// =========================================================================
// Organization-scoped API Key endpoints
// =========================================================================
// These endpoints allow managing API keys scoped to a specific organization

/**
* POST /organizations/:organizationId/api-keys
* Creates an API key scoped to the organization
*/
@Post('organizations/:organizationId/keys')
@UseGuards(ApiKeyGuard, OrganizationAccessGuard, RoleGuard)
@RequireRole('ADMIN', 'OWNER')
@ApiHeader({
name: 'X-API-Key',
description: 'API key for authentication',
required: true,
})
@ApiOperation({
summary: 'Create organization-scoped API key',
description: 'Creates an API key that is scoped to the organization. Requires ADMIN or OWNER role.',
})
async createOrgKey(
@Param('organizationId') organizationId: string,
@Body() dto: CreateApiKeyDto,
@Request() req: any,

Check failure on line 104 in app/backend/src/api-keys/api-keys.controller.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type

Check failure on line 104 in app/backend/src/api-keys/api-keys.controller.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'req' is defined but never used
) {
// Set the organization_id in the DTO
dto.organization_id = organizationId;
return this.service.create(dto);
}

/**
* GET /organizations/:organizationId/api-keys
* Lists all API keys for the organization
*/
@Get('organizations/:organizationId/keys')
@UseGuards(ApiKeyGuard, OrganizationAccessGuard)
@ApiHeader({
name: 'X-API-Key',
description: 'API key for authentication',
required: true,
})
@ApiOperation({
summary: 'List organization API keys',
description: 'Returns all API keys associated with this organization',
})
async listOrgKeys(
@Param('organizationId') organizationId: string,
@Query('cursor') cursor?: string,
@Query('limit') limit?: number,
) {
return this.service.listPaginated(undefined, cursor, limit);
}

/**
* DELETE /organizations/:organizationId/api-keys/:keyId
* Revokes an API key for the organization
*/
@Delete('organizations/:organizationId/keys/:keyId')
@UseGuards(ApiKeyGuard, OrganizationAccessGuard, RoleGuard)
@RequireRole('ADMIN', 'OWNER')
@ApiHeader({
name: 'X-API-Key',
description: 'API key for authentication',
required: true,
})
@ApiOperation({
summary: 'Revoke organization API key',
description: 'Revokes an API key. Requires ADMIN or OWNER role.',
})
async revokeOrgKey(
@Param('organizationId') organizationId: string,
@Param('keyId', ParseUUIDPipe) keyId: string,
) {
return this.service.revoke(keyId);
}

/**
* POST /organizations/:organizationId/api-keys/:keyId/rotate
* Rotates an API key for the organization
*/
@Post('organizations/:organizationId/keys/:keyId/rotate')
@UseGuards(ApiKeyGuard, OrganizationAccessGuard, RoleGuard)
@RequireRole('ADMIN', 'OWNER')
@ApiHeader({
name: 'X-API-Key',
description: 'API key for authentication',
required: true,
})
@ApiOperation({
summary: 'Rotate organization API key',
description: 'Rotates an API key and returns a new one. Requires ADMIN or OWNER role.',
})
async rotateOrgKey(
@Param('organizationId') organizationId: string,
@Param('keyId', ParseUUIDPipe) keyId: string,
) {
return this.service.rotate(keyId);
}
}

6 changes: 5 additions & 1 deletion app/backend/src/api-keys/api-keys.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ export class ApiKeysRepository {
key_prefix: string;
scopes: ApiKeyScope[];
owner_id: string | null;
organization_id?: string | null;
monthly_quota: number;
}): Promise<ApiKeyRecord> {
const { data: row, error } = await this.client
.from('api_keys')
.insert(data)
.insert({
...data,
organization_id: data.organization_id || null,
})
.select()
.single();

Expand Down
1 change: 1 addition & 0 deletions app/backend/src/api-keys/api-keys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class ApiKeysService {
key_prefix: prefix,
scopes: dto.scopes,
owner_id: dto.owner_id ?? null,
organization_id: dto.organization_id ?? null,
monthly_quota: DEFAULT_QUOTA,
});

Expand Down
1 change: 1 addition & 0 deletions app/backend/src/api-keys/api-keys.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface ApiKeyRecord {
key_prefix: string;
scopes: ApiKeyScope[];
owner_id: string | null;
organization_id: string | null; // Foreign key to organizations table
is_active: boolean;
request_count: number;
monthly_quota: number;
Expand Down
5 changes: 5 additions & 0 deletions app/backend/src/api-keys/dto/create-api-key.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ArrayMinSize,
MaxLength,
} from 'class-validator';
Expand All @@ -23,4 +24,8 @@ export class CreateApiKeyDto {
@IsOptional()
@IsString()
owner_id?: string;

@IsOptional()
@IsUUID()
organization_id?: string; // Organization to scope this API key to
}
2 changes: 2 additions & 0 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ExportsModule } from "./exports/exports.module";
import { JobQueueModule } from "./job-queue/job-queue.module";
import { AuditModule } from "./audit/audit.module";
import { FeatureFlagsModule } from "./feature-flags/feature-flags.module";
import { OrganizationsModule } from "./organizations/organizations.module";
import { CustomThrottlerGuard } from "./auth/guards/custom-throttler.guard";
import { throttlerModuleProfiles } from "./config/rate-limit.config";

Expand All @@ -59,6 +60,7 @@ type AppImport =
}),
ThrottlerModule.forRoot(throttlerModuleProfiles),
SupabaseModule,
OrganizationsModule,
HealthModule,
AssetMetadataModule,
StellarModule,
Expand Down
4 changes: 4 additions & 0 deletions app/backend/src/auth/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './rate-limit-group.decorator';
export * from './require-scopes.decorator';
export * from './require-organization.decorator';
export * from './require-role.decorator';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';

export const REQUIRE_ORGANIZATION_KEY = 'require_organization';

/**
* Decorator to mark that a handler requires organization context
* The organization ID should be provided in the route parameter or request body
*/
export const RequireOrganization = () => SetMetadata(REQUIRE_ORGANIZATION_KEY, true);
10 changes: 10 additions & 0 deletions app/backend/src/auth/decorators/require-role.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
import { OrganizationRole } from '../../organizations/organizations.types';

export const REQUIRE_ROLE_KEY = 'require_role';

/**
* Decorator to mark that a handler requires one or more specific roles
* @param roles One or more organization roles required
*/
export const RequireRole = (...roles: OrganizationRole[]) => SetMetadata(REQUIRE_ROLE_KEY, roles);
15 changes: 15 additions & 0 deletions app/backend/src/auth/guards/api-key.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { ApiKeysService } from "../../api-keys/api-keys.service";
import { ApiKeyScope } from "../../api-keys/api-keys.types";
import { throttlerConfig } from "../../config/rate-limit.config";
import { REQUIRED_SCOPES_KEY } from "../decorators/require-scopes.decorator";
import { OrganizationContextService } from "../../organizations/organization-context.service";

@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(
private readonly apiKeysService: ApiKeysService,
private readonly orgContextService: OrganizationContextService,
private readonly reflector: Reflector,
) {}

Expand Down Expand Up @@ -58,13 +60,26 @@ export class ApiKeyGuard implements CanActivate {
}
}

// Extract organization context from API key
const organizationId = await this.orgContextService.getApiKeyOrganization(record);
const userId = this.orgContextService.getUserFromApiKey(record);

request.apiKey = {
id: record.id,
name: record.name,
scopes: record.scopes,
rateLimit: throttlerConfig.groups.authenticated.sustained.limit,
};

// Attach organization context to request if available
if (organizationId && userId) {
request.organizationContext = {
organization_id: organizationId,
user_id: userId,
apiKeyId: record.id,
};
}

return true;
}
}
4 changes: 4 additions & 0 deletions app/backend/src/auth/guards/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './api-key.guard';
export * from './custom-throttler.guard';
export * from './role.guard';
export * from './organization-access.guard';
79 changes: 79 additions & 0 deletions app/backend/src/auth/guards/organization-access.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
BadRequestException,
} from '@nestjs/common';
import { OrganizationsService } from '../../organizations/organizations.service';
import { OrganizationContextService } from '../../organizations/organization-context.service';

@Injectable()
export class OrganizationAccessGuard implements CanActivate {
private readonly logger = new Logger(OrganizationAccessGuard.name);

constructor(
private readonly organizationsService: OrganizationsService,
private readonly orgContextService: OrganizationContextService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { params, body } = request;

// Get organization ID from route parameter or request body
const organizationId =
params.organizationId || params.orgId || body?.organization_id || body?.orgId;

if (!organizationId) {
// If no org ID provided and this guard is used, something is wrong
throw new BadRequestException({
error: 'MISSING_ORGANIZATION_ID',
message: 'Organization ID is required',
});
}

// Check if we have organization context from API key
const orgContext = request.organizationContext;
if (!orgContext) {
throw new ForbiddenException({
error: 'NO_ORGANIZATION_CONTEXT',
message: 'API key or authorization is required',
});
}

// Verify that the requested organization matches the API key's organization
if (organizationId !== orgContext.organization_id) {
this.logger.warn('Organization access denied', {
requestedOrg: organizationId,
apiKeyOrg: orgContext.organization_id,
userId: orgContext.user_id,
});

throw new ForbiddenException({
error: 'ORGANIZATION_ACCESS_DENIED',
message: 'You do not have access to this organization',
});
}

// Verify user is actually a member of this organization
const context_ = await this.organizationsService.getOrganizationContext(
orgContext.user_id,
organizationId,
);

if (!context_) {
throw new ForbiddenException({
error: 'NOT_ORGANIZATION_MEMBER',
message: 'User is not a member of this organization',
});
}

// Update request context with full information
request.organizationContext = context_;
request.organizationId = organizationId;

return true;
}
}
Loading
Loading