Skip to content

Commit 75dbff7

Browse files
authored
Merge branch 'main' into frontend_public_permissions
2 parents cf13301 + 2096f0c commit 75dbff7

10 files changed

Lines changed: 421 additions & 1 deletion

File tree

backend/src/common/data-injection.tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export enum UseCaseType {
182182
AGENTS_VALIDATE_USER_TOKEN = 'AGENTS_VALIDATE_USER_TOKEN',
183183
AGENTS_VALIDATE_TABLE_AI_REQUEST = 'AGENTS_VALIDATE_TABLE_AI_REQUEST',
184184
AGENTS_VALIDATE_CONNECTION_EDIT = 'AGENTS_VALIDATE_CONNECTION_EDIT',
185+
AGENTS_SET_PUBLIC_PERMISSIONS = 'AGENTS_SET_PUBLIC_PERMISSIONS',
185186
AGENTS_GET_AI_CONNECTION_CONTEXT = 'AGENTS_GET_AI_CONNECTION_CONTEXT',
186187
AGENTS_GET_AI_CONNECTION_TABLES = 'AGENTS_GET_AI_CONNECTION_TABLES',
187188
AGENTS_GET_AI_TABLE_STRUCTURE = 'AGENTS_GET_AI_TABLE_STRUCTURE',

backend/src/microservices/agents-microservice/agents.controller.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
AiSampleRowsRO,
1616
CompanySubscriptionInfoRO,
1717
PermissionAllowedRO,
18+
PublicPermissionsRO,
1819
ValidatedUserTokenRO,
1920
} from './data-structures/agents-responses.ds.js';
2021
import {
@@ -26,6 +27,7 @@ import {
2627
} from './dto/agents-ai-data.dtos.js';
2728
import { ValidateConnectionEditDto, ValidateTableAiRequestDto, ValidateUserTokenDto } from './dto/agents-auth.dtos.js';
2829
import { GetCompanySubscriptionInfoDto } from './dto/agents-company.dtos.js';
30+
import { SetAgentsPublicPermissionsDto } from './dto/agents-public-permissions.dtos.js';
2931
import {
3032
IExecuteAiAggregationPipeline,
3133
IExecuteAiRawQuery,
@@ -35,6 +37,7 @@ import {
3537
IGetAiTableStructure,
3638
IGetCompanySubscriptionInfo,
3739
IScanAndCreateSettings,
40+
ISetPublicPermissions,
3841
IValidateConnectionEdit,
3942
IValidateTableAiRequest,
4043
IValidateUserToken,
@@ -54,6 +57,8 @@ export class AgentsController {
5457
private readonly validateTableAiRequestUseCase: IValidateTableAiRequest,
5558
@Inject(UseCaseType.AGENTS_VALIDATE_CONNECTION_EDIT)
5659
private readonly validateConnectionEditUseCase: IValidateConnectionEdit,
60+
@Inject(UseCaseType.AGENTS_SET_PUBLIC_PERMISSIONS)
61+
private readonly setPublicPermissionsUseCase: ISetPublicPermissions,
5762
@Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT)
5863
private readonly getAiConnectionContextUseCase: IGetAiConnectionContext,
5964
@Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES)
@@ -102,6 +107,26 @@ export class AgentsController {
102107
);
103108
}
104109

110+
@ApiOperation({
111+
summary: 'Grant public (anonymous) read on connection tables — website-generation agent flow',
112+
description:
113+
'Re-checks Cedar connection:edit for the given user, then merges the tables into the existing public ' +
114+
'policy (mode=merge, default) or replaces it (mode=replace). Called by agents-core after the user ' +
115+
'approved the set_public_read_permissions consent question.',
116+
})
117+
@ApiResponse({ status: 201, type: PublicPermissionsRO })
118+
@ApiBody({ type: SetAgentsPublicPermissionsDto })
119+
@Post('/connection/public-permissions/:connectionId')
120+
public async setPublicPermissions(
121+
@SlugUuid('connectionId') connectionId: string,
122+
@Body() body: SetAgentsPublicPermissionsDto,
123+
): Promise<PublicPermissionsRO> {
124+
return await this.setPublicPermissionsUseCase.execute(
125+
{ connectionId, userId: body.userId, tables: body.tables, mode: body.mode ?? 'merge' },
126+
InTransactionEnum.OFF,
127+
);
128+
}
129+
105130
@ApiOperation({ summary: 'Get AI-relevant connection context (type, schema, MongoDB flag)' })
106131
@ApiResponse({ status: 201, type: AiConnectionContextRO })
107132
@ApiBody({ type: AiDataRequestBaseDto })

backend/src/microservices/agents-microservice/agents.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { GetAiSampleRowsUseCase } from './use-cases/get-ai-sample-rows.use.case.
1212
import { GetAiTableStructureUseCase } from './use-cases/get-ai-table-structure.use.case.js';
1313
import { GetCompanySubscriptionInfoUseCase } from './use-cases/get-company-subscription-info.use.case.js';
1414
import { ScanAndCreateSettingsUseCase } from './use-cases/scan-and-create-settings.use.case.js';
15+
import { SetPublicPermissionsUseCase } from './use-cases/set-public-permissions.use.case.js';
1516
import { ValidateConnectionEditUseCase } from './use-cases/validate-connection-edit.use.case.js';
1617
import { ValidateTableAiRequestUseCase } from './use-cases/validate-table-ai-request.use.case.js';
1718
import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.case.js';
@@ -35,6 +36,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca
3536
provide: UseCaseType.AGENTS_VALIDATE_CONNECTION_EDIT,
3637
useClass: ValidateConnectionEditUseCase,
3738
},
39+
{
40+
provide: UseCaseType.AGENTS_SET_PUBLIC_PERMISSIONS,
41+
useClass: SetPublicPermissionsUseCase,
42+
},
3843
{
3944
provide: UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT,
4045
useClass: GetAiConnectionContextUseCase,

backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ export class PermissionAllowedRO {
2222
allowed: boolean;
2323
}
2424

25+
export class PublicPermissionsRO {
26+
@ApiProperty({ description: 'Whether public access is enabled for this connection after the update.' })
27+
enabled: boolean;
28+
29+
@ApiProperty({
30+
type: 'array',
31+
items: { type: 'object', additionalProperties: true },
32+
description: 'The effective public tables after the update (merged view, not just the request).',
33+
})
34+
tables: Array<{ tableName: string; readableColumns?: Array<string> }>;
35+
}
36+
2537
export class AiConnectionContextRO {
2638
@ApiProperty()
2739
connectionId: string;

backend/src/microservices/agents-microservice/data-structures/agents.ds.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ export class ValidateConnectionEditDs {
1111
connectionId: string;
1212
}
1313

14+
export class SetPublicPermissionsDs {
15+
userId: string;
16+
connectionId: string;
17+
tables: Array<{ tableName: string; readableColumns?: Array<string> }>;
18+
mode: 'merge' | 'replace';
19+
}
20+
1421
export class AiDataRequestDs {
1522
connectionId: string;
1623
userId: string;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { Type } from 'class-transformer';
3+
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
4+
import { PublicTablePermissionDto } from '../../../entities/cedar-authorization/dto/public-permissions.dto.js';
5+
6+
export class SetAgentsPublicPermissionsDto {
7+
@ApiProperty({ description: 'The user on whose behalf the grant is made — must hold connection:edit (Cedar).' })
8+
@IsNotEmpty()
9+
@IsString()
10+
userId: string;
11+
12+
@ApiProperty({
13+
description:
14+
'Tables to expose to unauthenticated (public) users. With mode=merge (default) they are UNIONED into the ' +
15+
'existing public permissions; with mode=replace they become the whole set (empty array disables public access).',
16+
type: [PublicTablePermissionDto],
17+
})
18+
@IsArray()
19+
@ValidateNested({ each: true })
20+
@Type(() => PublicTablePermissionDto)
21+
tables: Array<PublicTablePermissionDto>;
22+
23+
@ApiPropertyOptional({ enum: ['merge', 'replace'], default: 'merge' })
24+
@IsOptional()
25+
@IsIn(['merge', 'replace'])
26+
mode?: 'merge' | 'replace';
27+
}

backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
GetAiTableStructureDs,
88
GetCompanySubscriptionInfoDs,
99
ScanAndCreateSettingsDs,
10+
SetPublicPermissionsDs,
1011
ValidateConnectionEditDs,
1112
ValidateTableAiRequestDs,
1213
} from '../data-structures/agents.ds.js';
@@ -17,6 +18,7 @@ import {
1718
AiSampleRowsRO,
1819
CompanySubscriptionInfoRO,
1920
PermissionAllowedRO,
21+
PublicPermissionsRO,
2022
ValidatedUserTokenRO,
2123
} from '../data-structures/agents-responses.ds.js';
2224

@@ -32,6 +34,10 @@ export interface IValidateConnectionEdit {
3234
execute(inputData: ValidateConnectionEditDs, inTransaction: InTransactionEnum): Promise<PermissionAllowedRO>;
3335
}
3436

37+
export interface ISetPublicPermissions {
38+
execute(inputData: SetPublicPermissionsDs, inTransaction: InTransactionEnum): Promise<PublicPermissionsRO>;
39+
}
40+
3541
export interface IGetAiConnectionContext {
3642
execute(inputData: AiDataRequestDs, inTransaction: InTransactionEnum): Promise<AiConnectionContextRO>;
3743
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { ForbiddenException, Inject, Injectable, Logger, Scope } from '@nestjs/common';
2+
import AbstractUseCase from '../../../common/abstract-use.case.js';
3+
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
4+
import { BaseType } from '../../../common/data-injection.tokens.js';
5+
import { CedarAction } from '../../../entities/cedar-authorization/cedar-action-map.js';
6+
import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js';
7+
import { IPublicTablePermission } from '../../../entities/cedar-authorization/cedar-policy-generator.js';
8+
import { Messages } from '../../../exceptions/text/messages.js';
9+
import { SetPublicPermissionsDs } from '../data-structures/agents.ds.js';
10+
import { PublicPermissionsRO } from '../data-structures/agents-responses.ds.js';
11+
import { ISetPublicPermissions } from './agents-use-cases.interface.js';
12+
13+
// Grants public (anonymous) read access on connection tables on behalf of the website-generation
14+
// agent (agents-core `set_public_read_permissions`, ADR sitenova/03 §6.4). agents-core has already
15+
// collected the explicit user approval; this side re-checks that the approving user actually holds
16+
// Cedar connection:edit, then MERGES the requested tables into the existing public policy (merge by
17+
// default — the agent can only ever ADD public tables, never remove an admin's; `replace` exists
18+
// for explicit resets). savePublicPermissions re-validates the generated policy and invalidates
19+
// the policy cache, so the grant is effective immediately.
20+
21+
// Union two public-table sets: an all-columns grant (absent/empty readableColumns) on either side
22+
// wins for that table; otherwise the column whitelists are unioned.
23+
export function mergePublicTables(
24+
existing: Array<IPublicTablePermission>,
25+
requested: Array<IPublicTablePermission>,
26+
): Array<IPublicTablePermission> {
27+
const merged = new Map<string, IPublicTablePermission>();
28+
for (const table of [...existing, ...requested]) {
29+
if (!table.tableName) continue;
30+
const previous = merged.get(table.tableName);
31+
if (!previous) {
32+
merged.set(table.tableName, {
33+
tableName: table.tableName,
34+
...(table.readableColumns?.length ? { readableColumns: [...table.readableColumns] } : {}),
35+
});
36+
continue;
37+
}
38+
if (!previous.readableColumns?.length || !table.readableColumns?.length) {
39+
merged.set(table.tableName, { tableName: table.tableName });
40+
continue;
41+
}
42+
merged.set(table.tableName, {
43+
tableName: table.tableName,
44+
readableColumns: [...new Set([...previous.readableColumns, ...table.readableColumns])],
45+
});
46+
}
47+
return [...merged.values()];
48+
}
49+
50+
@Injectable({ scope: Scope.REQUEST })
51+
export class SetPublicPermissionsUseCase
52+
extends AbstractUseCase<SetPublicPermissionsDs, PublicPermissionsRO>
53+
implements ISetPublicPermissions
54+
{
55+
private readonly logger = new Logger(SetPublicPermissionsUseCase.name);
56+
57+
constructor(
58+
@Inject(BaseType.GLOBAL_DB_CONTEXT)
59+
protected _dbContext: IGlobalDatabaseContext,
60+
private readonly cedarAuthService: CedarAuthorizationService,
61+
) {
62+
super();
63+
}
64+
65+
protected async implementation(inputData: SetPublicPermissionsDs): Promise<PublicPermissionsRO> {
66+
const { userId, connectionId, tables, mode } = inputData;
67+
const requestedNames = tables.map((t) => t.tableName).join(', ');
68+
69+
const allowed = await this.cedarAuthService.validate({
70+
userId,
71+
action: CedarAction.ConnectionEdit,
72+
connectionId,
73+
});
74+
if (!allowed) {
75+
// Audit trail: this is the most security-relevant refusal on the endpoint — the approving
76+
// user does not hold connection:edit, so the agent-collected approval is not honored.
77+
this.logger.warn(
78+
`Public-read grant REFUSED (no connection:edit): connection=${connectionId} user=${userId} requested=[${requestedNames}]`,
79+
);
80+
throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS);
81+
}
82+
83+
let effective: Array<IPublicTablePermission> = tables;
84+
if (mode !== 'replace') {
85+
const existing = await this.cedarAuthService.getPublicPermissions(connectionId);
86+
effective = mergePublicTables(existing.tables, tables);
87+
}
88+
89+
const saved = await this.cedarAuthService.savePublicPermissions(connectionId, effective);
90+
// Audit trail for every grant (this endpoint changes what ANONYMOUS visitors can read).
91+
this.logger.log(
92+
`Public-read grant: connection=${connectionId} user=${userId} mode=${mode} requested=[${requestedNames}] ` +
93+
`-> enabled=${saved.enabled} tables=[${saved.tables.map((t) => t.tableName).join(', ')}]`,
94+
);
95+
return { enabled: saved.enabled, tables: saved.tables };
96+
}
97+
}

backend/src/microservices/sitenova-microservice/guards/sitenova-public-read.guard.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
1+
import {
2+
BadRequestException,
3+
CanActivate,
4+
ExecutionContext,
5+
ForbiddenException,
6+
Injectable,
7+
Logger,
8+
} from '@nestjs/common';
29
import { Request } from 'express';
310
import { CedarAction, PUBLIC_USER_ID } from '../../../entities/cedar-authorization/cedar-action-map.js';
411
import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js';
@@ -10,6 +17,8 @@ import { Messages } from '../../../exceptions/text/messages.js';
1017
// here; column visibility follows the connection's public-read policy downstream.
1118
@Injectable()
1219
export class SitenovaPublicReadGuard implements CanActivate {
20+
private readonly logger = new Logger(SitenovaPublicReadGuard.name);
21+
1322
constructor(private readonly cedarAuthService: CedarAuthorizationService) {}
1423

1524
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -23,8 +32,14 @@ export class SitenovaPublicReadGuard implements CanActivate {
2332
throw new BadRequestException(Messages.TABLE_NAME_MISSING);
2433
}
2534

35+
// The deny REASON is logged on purpose: an anonymous 403 here is what a generated site's
36+
// visitor hits when the public grant is missing/partial, and the two causes (no public
37+
// policy at all vs. this specific table not granted) have different fixes.
2638
const publicEnabled = await this.cedarAuthService.isPublicAccessEnabled(connectionId);
2739
if (!publicEnabled) {
40+
this.logger.warn(
41+
`Public read DENIED (connection has NO public policy): connection=${connectionId} table=${tableName}`,
42+
);
2843
throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS);
2944
}
3045
const allowed = await this.cedarAuthService.validate({
@@ -35,6 +50,9 @@ export class SitenovaPublicReadGuard implements CanActivate {
3550
publicAccess: true,
3651
});
3752
if (!allowed) {
53+
this.logger.warn(
54+
`Public read DENIED (table not in the public policy): connection=${connectionId} table=${tableName}`,
55+
);
3856
throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS);
3957
}
4058
return true;

0 commit comments

Comments
 (0)