diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index f3b9bc321..e2afdaa77 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -183,6 +183,7 @@ export enum UseCaseType { AGENTS_VALIDATE_TABLE_AI_REQUEST = 'AGENTS_VALIDATE_TABLE_AI_REQUEST', AGENTS_VALIDATE_CONNECTION_EDIT = 'AGENTS_VALIDATE_CONNECTION_EDIT', AGENTS_SET_PUBLIC_PERMISSIONS = 'AGENTS_SET_PUBLIC_PERMISSIONS', + AGENTS_SET_SITE_RUNTIME_POLICY = 'AGENTS_SET_SITE_RUNTIME_POLICY', AGENTS_GET_AI_CONNECTION_CONTEXT = 'AGENTS_GET_AI_CONNECTION_CONTEXT', AGENTS_GET_AI_CONNECTION_TABLES = 'AGENTS_GET_AI_CONNECTION_TABLES', AGENTS_GET_AI_TABLE_STRUCTURE = 'AGENTS_GET_AI_TABLE_STRUCTURE', diff --git a/backend/src/entities/connection/connection.entity.ts b/backend/src/entities/connection/connection.entity.ts index 907e1cd2a..0578b3267 100644 --- a/backend/src/entities/connection/connection.entity.ts +++ b/backend/src/entities/connection/connection.entity.ts @@ -120,6 +120,16 @@ export class ConnectionEntity { @Column({ type: 'text', nullable: true, default: null }) public_cedar_policy?: string | null; + // Server-side site data contract for the SiteNova generated-site runtime API (plan 13 §4), + // served to universal-backend inside the internal credentials response. Presence marks the + // connection as backing a real generated site (Step 2a allow-list); its shape (auth/write/ + // owned-read rules) is consumed by universal-backend's SiteContractService in Step 2b. NULL for + // every ordinary admin-panel connection. + // jsonb value typed `Record` to match the existing jsonb columns + // (e.g. TableFiltersEntity.filters) and keep TypeORM's update() deep-partial typings happy. + @Column({ type: 'jsonb', nullable: true, default: null }) + site_runtime_policy?: Record | null; + /** * Non-persisted flag indicating whether credentials are currently in decrypted state. * Used by @BeforeUpdate to decide whether encryption is needed. diff --git a/backend/src/entities/connection/repository/connection.repository.interface.ts b/backend/src/entities/connection/repository/connection.repository.interface.ts index 51bf9a9fd..1cfefc039 100644 --- a/backend/src/entities/connection/repository/connection.repository.interface.ts +++ b/backend/src/entities/connection/repository/connection.repository.interface.ts @@ -32,6 +32,13 @@ export interface IConnectionRepository { updateConnectionPublicCedarPolicy(connectionId: string, publicCedarPolicy: string | null): Promise; + getConnectionSiteRuntimePolicy(connectionId: string): Promise | null>; + + updateConnectionSiteRuntimePolicy( + connectionId: string, + siteRuntimePolicy: Record | null, + ): Promise; + findOneAgentConnectionByToken(connectionToken: string): Promise; decryptConnectionField(field: string): string; diff --git a/backend/src/entities/connection/repository/custom-connection-repository-extension.ts b/backend/src/entities/connection/repository/custom-connection-repository-extension.ts index 5b354b9b2..68d38f882 100644 --- a/backend/src/entities/connection/repository/custom-connection-repository-extension.ts +++ b/backend/src/entities/connection/repository/custom-connection-repository-extension.ts @@ -216,6 +216,25 @@ export const customConnectionRepositoryExtension: IConnectionRepository & .execute(); }, + async getConnectionSiteRuntimePolicy(connectionId: string): Promise | null> { + const connection = await this.createQueryBuilder('connection') + .select(['connection.id', 'connection.site_runtime_policy']) + .where('connection.id = :connectionId', { connectionId }) + .getOne(); + return connection?.site_runtime_policy ?? null; + }, + + async updateConnectionSiteRuntimePolicy( + connectionId: string, + siteRuntimePolicy: Record | null, + ): Promise { + await this.createQueryBuilder() + .update(ConnectionEntity) + .set({ site_runtime_policy: siteRuntimePolicy }) + .where('id = :connectionId', { connectionId }) + .execute(); + }, + async isUserFromConnection(userId: string, connectionId: string): Promise { const qb = this.createQueryBuilder('connection') .leftJoin('connection.groups', 'group') diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index 13f87efbf..4d8fdb087 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -105,6 +105,9 @@ export const Messages = { PUBLIC_POLICY_ACTION_NOT_ALLOWED: 'Public permissions may only grant QueryTable (table:query) and ColumnRead (column:read)', PUBLIC_ACCESS_NOT_CONFIGURED: 'Public access is not configured for this connection', + PUBLIC_GRANT_ON_AUTH_TABLE_NOT_ALLOWED: + "Public read cannot be granted on the site's visitor accounts table or its credential column", + SITE_RUNTIME_POLICY_INVALID: 'Site runtime policy must be a JSON object', CSV_EXPORT_FAILED: 'CSV export failed', CSV_EXPORT_DISABLED: 'CSV export is disabled', CSV_IMPORT_FAILED: 'CSV import failed', diff --git a/backend/src/microservices/agents-microservice/agents.controller.ts b/backend/src/microservices/agents-microservice/agents.controller.ts index 6c39a7e37..290e3a5ae 100644 --- a/backend/src/microservices/agents-microservice/agents.controller.ts +++ b/backend/src/microservices/agents-microservice/agents.controller.ts @@ -16,6 +16,7 @@ import { CompanySubscriptionInfoRO, PermissionAllowedRO, PublicPermissionsRO, + SiteRuntimePolicyRO, ValidatedUserTokenRO, } from './data-structures/agents-responses.ds.js'; import { @@ -28,6 +29,7 @@ import { import { ValidateConnectionEditDto, ValidateTableAiRequestDto, ValidateUserTokenDto } from './dto/agents-auth.dtos.js'; import { GetCompanySubscriptionInfoDto } from './dto/agents-company.dtos.js'; import { SetAgentsPublicPermissionsDto } from './dto/agents-public-permissions.dtos.js'; +import { SetSiteRuntimePolicyDto } from './dto/agents-site-runtime-policy.dtos.js'; import { IExecuteAiAggregationPipeline, IExecuteAiRawQuery, @@ -38,6 +40,7 @@ import { IGetCompanySubscriptionInfo, IScanAndCreateSettings, ISetPublicPermissions, + ISetSiteRuntimePolicy, IValidateConnectionEdit, IValidateTableAiRequest, IValidateUserToken, @@ -59,6 +62,8 @@ export class AgentsController { private readonly validateConnectionEditUseCase: IValidateConnectionEdit, @Inject(UseCaseType.AGENTS_SET_PUBLIC_PERMISSIONS) private readonly setPublicPermissionsUseCase: ISetPublicPermissions, + @Inject(UseCaseType.AGENTS_SET_SITE_RUNTIME_POLICY) + private readonly setSiteRuntimePolicyUseCase: ISetSiteRuntimePolicy, @Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT) private readonly getAiConnectionContextUseCase: IGetAiConnectionContext, @Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES) @@ -127,6 +132,26 @@ export class AgentsController { ); } + @ApiOperation({ + summary: 'Write the site data contract (runtime manifest) for a generated site — agent finalize flow', + description: + 'Re-checks Cedar connection:edit for the given user, then stores the manifest on the connection ' + + '(site_runtime_policy, replacing any previous one). universal-backend enforces it at the generated-site ' + + 'runtime: auth-table pinning, write allow-lists with ownership, owner-scoped reads (plan 13 Step 2b).', + }) + @ApiResponse({ status: 201, type: SiteRuntimePolicyRO }) + @ApiBody({ type: SetSiteRuntimePolicyDto }) + @Post('/connection/site-runtime-policy/:connectionId') + public async setSiteRuntimePolicy( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SetSiteRuntimePolicyDto, + ): Promise { + return await this.setSiteRuntimePolicyUseCase.execute( + { connectionId, userId: body.userId, policy: body.policy }, + InTransactionEnum.OFF, + ); + } + @ApiOperation({ summary: 'Get AI-relevant connection context (type, schema, MongoDB flag)' }) @ApiResponse({ status: 201, type: AiConnectionContextRO }) @ApiBody({ type: AiDataRequestBaseDto }) diff --git a/backend/src/microservices/agents-microservice/agents.module.ts b/backend/src/microservices/agents-microservice/agents.module.ts index 716d87b53..9194ac027 100644 --- a/backend/src/microservices/agents-microservice/agents.module.ts +++ b/backend/src/microservices/agents-microservice/agents.module.ts @@ -13,6 +13,7 @@ import { GetAiTableStructureUseCase } from './use-cases/get-ai-table-structure.u import { GetCompanySubscriptionInfoUseCase } from './use-cases/get-company-subscription-info.use.case.js'; import { ScanAndCreateSettingsUseCase } from './use-cases/scan-and-create-settings.use.case.js'; import { SetPublicPermissionsUseCase } from './use-cases/set-public-permissions.use.case.js'; +import { SetSiteRuntimePolicyUseCase } from './use-cases/set-site-runtime-policy.use.case.js'; import { ValidateConnectionEditUseCase } from './use-cases/validate-connection-edit.use.case.js'; import { ValidateTableAiRequestUseCase } from './use-cases/validate-table-ai-request.use.case.js'; import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.case.js'; @@ -40,6 +41,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca provide: UseCaseType.AGENTS_SET_PUBLIC_PERMISSIONS, useClass: SetPublicPermissionsUseCase, }, + { + provide: UseCaseType.AGENTS_SET_SITE_RUNTIME_POLICY, + useClass: SetSiteRuntimePolicyUseCase, + }, { provide: UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT, useClass: GetAiConnectionContextUseCase, diff --git a/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts b/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts index d9924db21..6d5068b24 100644 --- a/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts +++ b/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts @@ -34,6 +34,15 @@ export class PublicPermissionsRO { tables: Array<{ tableName: string; readableColumns?: Array }>; } +export class SiteRuntimePolicyRO { + @ApiProperty({ + type: 'object', + additionalProperties: true, + description: 'The site runtime policy (data contract) now stored on the connection.', + }) + policy: Record; +} + export class AiConnectionContextRO { @ApiProperty() connectionId: string; diff --git a/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts b/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts index 89a9a814c..d4ad5ba4a 100644 --- a/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts +++ b/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts @@ -18,6 +18,12 @@ export class SetPublicPermissionsDs { mode: 'merge' | 'replace'; } +export class SetSiteRuntimePolicyDs { + userId: string; + connectionId: string; + policy: Record; +} + export class AiDataRequestDs { connectionId: string; userId: string; diff --git a/backend/src/microservices/agents-microservice/dto/agents-site-runtime-policy.dtos.ts b/backend/src/microservices/agents-microservice/dto/agents-site-runtime-policy.dtos.ts new file mode 100644 index 000000000..abf49f9bb --- /dev/null +++ b/backend/src/microservices/agents-microservice/dto/agents-site-runtime-policy.dtos.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsObject, IsString } from 'class-validator'; + +export class SetSiteRuntimePolicyDto { + @ApiProperty({ description: 'The user on whose behalf the manifest is written — must hold connection:edit (Cedar).' }) + @IsNotEmpty() + @IsString() + userId: string; + + @ApiProperty({ + type: 'object', + additionalProperties: true, + description: + 'The site data contract (plan 13 §4): {auth, ownedRead, write}. Replaces the stored policy wholesale — ' + + 'the generation agent always proposes the complete manifest it derived from the schema it created. ' + + 'universal-backend parses it fail-closed, so unknown keys are inert and missing sections grant nothing.', + }) + @IsObject() + policy: Record; +} diff --git a/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts b/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts index 04b389a16..98cb8efbd 100644 --- a/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts +++ b/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts @@ -8,6 +8,7 @@ import { GetCompanySubscriptionInfoDs, ScanAndCreateSettingsDs, SetPublicPermissionsDs, + SetSiteRuntimePolicyDs, ValidateConnectionEditDs, ValidateTableAiRequestDs, } from '../data-structures/agents.ds.js'; @@ -19,6 +20,7 @@ import { CompanySubscriptionInfoRO, PermissionAllowedRO, PublicPermissionsRO, + SiteRuntimePolicyRO, ValidatedUserTokenRO, } from '../data-structures/agents-responses.ds.js'; @@ -38,6 +40,10 @@ export interface ISetPublicPermissions { execute(inputData: SetPublicPermissionsDs, inTransaction: InTransactionEnum): Promise; } +export interface ISetSiteRuntimePolicy { + execute(inputData: SetSiteRuntimePolicyDs, inTransaction: InTransactionEnum): Promise; +} + export interface IGetAiConnectionContext { execute(inputData: AiDataRequestDs, inTransaction: InTransactionEnum): Promise; } diff --git a/backend/src/microservices/agents-microservice/use-cases/set-public-permissions.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/set-public-permissions.use.case.ts index 69b209b4b..3d5ef0463 100644 --- a/backend/src/microservices/agents-microservice/use-cases/set-public-permissions.use.case.ts +++ b/backend/src/microservices/agents-microservice/use-cases/set-public-permissions.use.case.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Inject, Injectable, Logger, Scope } from '@nestjs/common'; +import { ForbiddenException, HttpException, HttpStatus, Inject, Injectable, Logger, Scope } from '@nestjs/common'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; @@ -80,6 +80,8 @@ export class SetPublicPermissionsUseCase throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS); } + await this.refuseGrantOnSiteAuthTable(connectionId, userId, tables); + let effective: Array = tables; if (mode !== 'replace') { const existing = await this.cedarAuthService.getPublicPermissions(connectionId); @@ -94,4 +96,35 @@ export class SetPublicPermissionsUseCase ); return { enabled: saved.enabled, tables: saved.tables }; } + + // "Never publicly grant the users/auth table or a password column" used to be generation-prompt + // text only (plan 13 §3) — a misbehaving agent could still expose every visitor account to the + // anonymous internet. With the site manifest stored server-side (Step 2b) the rule is enforced + // here: a requested table matching the manifest's auth table is refused outright, and so is any + // column whitelist naming the manifest's password column (that column holds credentials wherever + // it appears). Connections without a manifest (plain admin-panel connections) are unaffected. + private async refuseGrantOnSiteAuthTable( + connectionId: string, + userId: string, + tables: Array, + ): Promise { + const policy = await this._dbContext.connectionRepository.getConnectionSiteRuntimePolicy(connectionId); + const auth = policy?.auth as { tableName?: unknown; passwordField?: unknown } | undefined; + const authTable = typeof auth?.tableName === 'string' ? auth.tableName : null; + const passwordField = typeof auth?.passwordField === 'string' ? auth.passwordField : null; + if (!authTable) { + return; + } + for (const table of tables) { + const namesAuthTable = table.tableName === authTable; + const namesPasswordColumn = Boolean(passwordField && table.readableColumns?.includes(passwordField)); + if (namesAuthTable || namesPasswordColumn) { + this.logger.warn( + `Public-read grant REFUSED (site auth table/credential column): connection=${connectionId} ` + + `user=${userId} table=${table.tableName} authTable=${authTable}`, + ); + throw new HttpException({ message: Messages.PUBLIC_GRANT_ON_AUTH_TABLE_NOT_ALLOWED }, HttpStatus.BAD_REQUEST); + } + } + } } diff --git a/backend/src/microservices/agents-microservice/use-cases/set-site-runtime-policy.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/set-site-runtime-policy.use.case.ts new file mode 100644 index 000000000..713a5cf44 --- /dev/null +++ b/backend/src/microservices/agents-microservice/use-cases/set-site-runtime-policy.use.case.ts @@ -0,0 +1,69 @@ +import { ForbiddenException, HttpException, HttpStatus, Inject, Injectable, Logger, Scope } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { CedarAction } from '../../../entities/cedar-authorization/cedar-action-map.js'; +import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { SetSiteRuntimePolicyDs } from '../data-structures/agents.ds.js'; +import { SiteRuntimePolicyRO } from '../data-structures/agents-responses.ds.js'; +import { ISetSiteRuntimePolicy } from './agents-use-cases.interface.js'; + +// Writes the site data contract (plan 13 §4) onto the connection — the manifest universal-backend +// enforces at the generated-site runtime (auth-table pinning, write allow-lists, owner-scoped +// reads). Same owner-consent channel as set-public-permissions: agents-core has already collected +// the explicit user approval; this side re-checks that the approving user actually holds Cedar +// connection:edit and logs refusals. Unlike the public grant there is no merge mode — the agent +// derives the complete manifest from the schema it created and it replaces the stored one +// wholesale (a backfilled presence-only marker included). +@Injectable({ scope: Scope.REQUEST }) +export class SetSiteRuntimePolicyUseCase + extends AbstractUseCase + implements ISetSiteRuntimePolicy +{ + private readonly logger = new Logger(SetSiteRuntimePolicyUseCase.name); + + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly cedarAuthService: CedarAuthorizationService, + ) { + super(); + } + + protected async implementation(inputData: SetSiteRuntimePolicyDs): Promise { + const { userId, connectionId, policy } = inputData; + + // The DTO already requires an object; arrays satisfy @IsObject, so rule them out here — + // a jsonb array would make universal-backend's parser treat every section as absent. + if (typeof policy !== 'object' || policy === null || Array.isArray(policy)) { + throw new HttpException({ message: Messages.SITE_RUNTIME_POLICY_INVALID }, HttpStatus.BAD_REQUEST); + } + + const allowed = await this.cedarAuthService.validate({ + userId, + action: CedarAction.ConnectionEdit, + connectionId, + }); + if (!allowed) { + // Audit trail: the approving user does not hold connection:edit, so the agent-collected + // approval is not honored (mirror of the set-public-permissions refusal). + this.logger.warn( + `Site-runtime-policy write REFUSED (no connection:edit): connection=${connectionId} user=${userId}`, + ); + throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS); + } + + await this._dbContext.connectionRepository.updateConnectionSiteRuntimePolicy(connectionId, policy); + // Audit trail for every write: this manifest decides what site visitors can do at runtime. + const authTable = (policy.auth as Record | undefined)?.tableName ?? null; + const writeTables = Array.isArray(policy.write) + ? policy.write.map((entry: { table?: string }) => entry?.table).filter(Boolean) + : []; + this.logger.log( + `Site-runtime-policy written: connection=${connectionId} user=${userId} ` + + `authTable=${authTable} writeTables=[${writeTables.join(', ')}]`, + ); + return { policy }; + } +} diff --git a/backend/src/microservices/sitenova-microservice/data-structures/sitenova-internal-responses.ds.ts b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-internal-responses.ds.ts index ef8b49917..5b2920cc0 100644 --- a/backend/src/microservices/sitenova-microservice/data-structures/sitenova-internal-responses.ds.ts +++ b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-internal-responses.ds.ts @@ -61,6 +61,17 @@ export class SitenovaConnectionForSiteRuntimeRO { 'Lets the caller sign/verify the same tokens SitenovaEndUserAuthService does.', }) endUserJwtKey: string; + + @ApiPropertyOptional({ + type: 'object', + additionalProperties: true, + nullable: true, + description: + 'Server-side site data contract (plan 13 §4). Null unless this connection backs a generated ' + + 'site. universal-backend refuses connections with no contract when SITE_CONTRACT_REQUIRED is on ' + + '(Step 2a allow-list); Step 2b consumes its auth/write/owned-read rules.', + }) + siteRuntimePolicy?: Record | null; } export class SitenovaPublicReadValidationRO { diff --git a/backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts index 212689e90..9402ce495 100644 --- a/backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts +++ b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts @@ -2,12 +2,17 @@ // keeping it cryptographically and logically separate from RocketAdmin platform user tokens. export const SITENOVA_ENDUSER_AUDIENCE = 'sitenova:enduser'; -// Token lifetime for generated-site visitors. -export const SITENOVA_ENDUSER_TOKEN_TTL = '7d'; +// Token lifetime for generated-site visitors. Mirrors universal-backend's ENDUSER_TOKEN_TTL +// (plan 13 Step 1): shortened from 7d because these tokens have no server-side revocation yet, and +// kept identical across both services so a token issued by either has the same lifetime during +// cutover. Read once at module load from the shared env var; unset defaults to 24h. +export const SITENOVA_ENDUSER_TOKEN_TTL = + process.env.ENDUSER_TOKEN_TTL && process.env.ENDUSER_TOKEN_TTL.length > 0 ? process.env.ENDUSER_TOKEN_TTL : '24h'; // JWT payload issued to a generated-site end-user on register/login. export interface SitenovaEndUserTokenPayload { sub: string; // the user's identifier in the connection's users table (email by default) + uid?: string; // the user's row primary key — basis for row-level authorization (plan 13); optional during cutover cid: string; // connectionId the token is bound to aud: string; // SITENOVA_ENDUSER_AUDIENCE } diff --git a/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts b/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts index 91fbe0696..d455f7f55 100644 --- a/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts +++ b/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts @@ -26,10 +26,22 @@ export class SitenovaEndUserAuthService { private readonly _dbContext: IGlobalDatabaseContext, ) {} - public async signEndUserToken(connectionId: string, sub: string): Promise { + // `uid` (the users-table row primary key) is the basis for row-level authorization in the + // universal-backend runtime (plan 13). It is optional here: the core's own register/login sites + // are being retired to universal-backend, so the core keeps issuing uid-less tokens, and the + // universal-backend verifier accepts them through its grace window. Verification is symmetric + // regardless — same key, same audience — so a uid-bearing token from either service validates on + // the other during cutover. + public async signEndUserToken(connectionId: string, sub: string, uid?: string): Promise { const key = await this.getOrCreateSigningKey(connectionId); const payload: SitenovaEndUserTokenPayload = { sub, cid: connectionId, aud: SITENOVA_ENDUSER_AUDIENCE }; - return jwt.sign(payload, key, { algorithm: 'HS256', expiresIn: SITENOVA_ENDUSER_TOKEN_TTL }); + if (uid !== undefined) { + payload.uid = uid; + } + return jwt.sign(payload, key, { + algorithm: 'HS256', + expiresIn: SITENOVA_ENDUSER_TOKEN_TTL as jwt.SignOptions['expiresIn'], + }); } // Returns the decoded payload when the token is a valid end-user token bound to this connection, diff --git a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-get-connection.use.case.ts b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-get-connection.use.case.ts index 3d6800838..8cc7e34fd 100644 --- a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-get-connection.use.case.ts +++ b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-get-connection.use.case.ts @@ -55,6 +55,9 @@ export class SitenovaGetConnectionUseCase dataCenter: connection.dataCenter, }, endUserJwtKey, + // Rides the existing 60 s cache on universal-backend — no extra round trip. Null for any + // connection that does not back a generated site (plan 13 Step 2a). + siteRuntimePolicy: connection.site_runtime_policy ?? null, }; } } diff --git a/backend/src/migrations/1785915298819-AddSiteRuntimePolicyToConnection.ts b/backend/src/migrations/1785915298819-AddSiteRuntimePolicyToConnection.ts new file mode 100644 index 000000000..9d8ffe51c --- /dev/null +++ b/backend/src/migrations/1785915298819-AddSiteRuntimePolicyToConnection.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSiteRuntimePolicyToConnection1785915298819 implements MigrationInterface { + name = 'AddSiteRuntimePolicyToConnection1785915298819'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "connection" ADD "site_runtime_policy" jsonb`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "connection" DROP COLUMN "site_runtime_policy"`); + } +} diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-agents-microservice-public-permissions-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-agents-microservice-public-permissions-e2e.test.ts index 94bdb7fd6..7627ea3a3 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-agents-microservice-public-permissions-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-agents-microservice-public-permissions-e2e.test.ts @@ -220,3 +220,84 @@ test.serial(`${currentTest} refuses a user without connection:edit (403) and gra const anonymous = await anonymousRowsRequest(connectionId, testTableName); t.is(anonymous.status, 403); }); + +// --- plan 13 step 2b: the site runtime policy (manifest) write + the auth-table grant refusal --- + +function siteRuntimePolicyRequest(connectionId: string, body: Record): request.Test { + return request(app.getHttpServer()) + .post(`/internal/agents/connection/site-runtime-policy/${connectionId}`) + .send(body) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); +} + +currentTest = 'POST /internal/agents/connection/site-runtime-policy/:connectionId (internal, microservice JWT)'; + +test.serial(`${currentTest} stores the manifest and blocks public grants on its auth table`, async (t) => { + const { userId, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const manifest = { + auth: { + tableName: 'site_users', + emailField: 'email', + passwordField: 'password', + idColumn: 'id', + returnableColumns: ['id', 'email'], + allowedExtraColumns: [], + registrationOpen: true, + }, + ownedRead: [], + write: [], + }; + const written = await siteRuntimePolicyRequest(connectionId, { userId, policy: manifest }); + t.is(written.status, 201); + t.is(JSON.parse(written.text).policy.auth.tableName, 'site_users'); + + // The rule that used to be generation-prompt text only: the manifest's auth table can never be + // publicly granted... + const refusedTable = await grantRequest(connectionId, { userId, tables: [{ tableName: 'site_users' }] }); + t.is(refusedTable.status, 400); + + // ...and neither can a column whitelist naming the manifest's credential column, on any table. + const refusedColumn = await grantRequest(connectionId, { + userId, + tables: [{ tableName: testTableName, readableColumns: ['password'] }], + }); + t.is(refusedColumn.status, 400); + + // Nothing was granted by the refused requests, and unrelated tables still grant normally. + const anonymousBefore = await anonymousRowsRequest(connectionId, testTableName); + t.is(anonymousBefore.status, 403); + const granted = await grantRequest(connectionId, { + userId, + tables: [{ tableName: testTableName, readableColumns: [testTableColumnName] }], + }); + t.is(granted.status, 201); +}); + +test.serial(`${currentTest} refuses a user without connection:edit (403) and writes nothing`, async (t) => { + const { userId, connectionId } = await createConnectionAndTable(); + const foreignUserToken = (await registerUserAndReturnUserInfo(app)).token; + const foreignUserId = userIdFromCookieToken(foreignUserToken); + + const refused = await siteRuntimePolicyRequest(connectionId, { + userId: foreignUserId, + policy: { auth: { tableName: 'site_users', emailField: 'email', passwordField: 'password' } }, + }); + t.is(refused.status, 403); + + // No manifest was stored: a public grant on that table by the real owner still succeeds. + const granted = await grantRequest(connectionId, { userId, tables: [{ tableName: 'site_users' }] }); + t.is(granted.status, 201); +}); + +test.serial(`${currentTest} refuses a policy that is not a JSON object (400)`, async (t) => { + const { userId, connectionId } = await createConnectionAndTable(); + + const arrayPolicy = await siteRuntimePolicyRequest(connectionId, { userId, policy: [{ auth: {} }] }); + t.is(arrayPolicy.status, 400); + + const stringPolicy = await siteRuntimePolicyRequest(connectionId, { userId, policy: 'not-an-object' }); + t.is(stringPolicy.status, 400); +});