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
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,8 @@ export class RequestInfoFromTableWithAIUseCaseV7
query,
connectionType: foundConnection.type as ConnectionTypesEnum,
connectionId: foundConnection.id,
validateTableRead: (referencedTableName) =>
this.cedarPermissions.improvedCheckTableRead(userId, foundConnection.id, referencedTableName),
getReadableTables: (tableNames) =>
this.cedarPermissions.getReadableTables(userId, foundConnection.id, tableNames),
listAllTableNames: async () => (await dataAccessObject.getTablesFromDB()).map((table) => table.tableName),
});
const wrappedQuery = wrapQueryWithLimit(query, foundConnection.type as ConnectionTypesEnum);
Expand Down Expand Up @@ -354,39 +354,26 @@ export class RequestInfoFromTableWithAIUseCaseV7

// Only expose the structure of related tables the user is permitted to
// read — otherwise foreign-key traversal would leak the schema of tables
// the user has no access to.
const referencedTablesStructures = [];
const structurePromises = referencedTableNamesAndColumns.flatMap((referencedTable) =>
referencedTable.referenced_by.map(async (table) => {
const canRead = await this.cedarPermissions.improvedCheckTableRead(
userId,
foundConnection.id,
table.table_name,
);
if (!canRead) {
return null;
}
const structure = await dao.getTableStructure(table.table_name, userEmail);
return { tableName: table.table_name, structure };
}),
// the user has no access to. One batched permission check covers both
// directions of the relation.
const referencedByTableNames = referencedTableNamesAndColumns.flatMap((referencedTable) =>
referencedTable.referenced_by.map((table) => table.table_name),
);
referencedTablesStructures.push(...(await Promise.all(structurePromises)).filter((item) => item !== null));

const foreignTablesStructures = [];
const foreignTablesStructurePromises = tableForeignKeys.map(async (foreignKey) => {
const canRead = await this.cedarPermissions.improvedCheckTableRead(
userId,
foundConnection.id,
foreignKey.referenced_table_name,
);
if (!canRead) {
return null;
}
const structure = await dao.getTableStructure(foreignKey.referenced_table_name, userEmail);
return { tableName: foreignKey.referenced_table_name, structure };
});
foreignTablesStructures.push(
...(await Promise.all(foreignTablesStructurePromises)).filter((item) => item !== null),
const foreignTableNames = tableForeignKeys.map((foreignKey) => foreignKey.referenced_table_name);
const readableTables = await this.cedarPermissions.getReadableTables(userId, foundConnection.id, [
...referencedByTableNames,
...foreignTableNames,
]);

const referencedTablesStructures = await Promise.all(
referencedByTableNames
.filter((name) => readableTables.has(name))
.map(async (name) => ({ tableName: name, structure: await dao.getTableStructure(name, userEmail) })),
);
const foreignTablesStructures = await Promise.all(
foreignTableNames
.filter((name) => readableTables.has(name))
.map(async (name) => ({ tableName: name, structure: await dao.getTableStructure(name, userEmail) })),
);

return {
Expand Down Expand Up @@ -416,15 +403,16 @@ export class RequestInfoFromTableWithAIUseCaseV7
new Set(tableNames.map((name) => name?.trim()).filter((name): name is string => Boolean(name))),
);

for (const tableName of uniqueTableNames) {
const canRead = await this.cedarPermissions.improvedCheckTableRead(userId, connectionId, tableName);
if (!canRead) {
this.logger.warn(
`AI request blocked for user ${userId} on connection ${connectionId}: ` +
`no read permission for table "${tableName}"`,
);
throw new ForbiddenException(Messages.NO_READ_PERMISSION_FOR_TABLE(tableName));
}
if (uniqueTableNames.length === 0) return;

const readableTables = await this.cedarPermissions.getReadableTables(userId, connectionId, uniqueTableNames);
const blockedTable = uniqueTableNames.find((tableName) => !readableTables.has(tableName));
if (blockedTable !== undefined) {
this.logger.warn(
`AI request blocked for user ${userId} on connection ${connectionId}: ` +
`no read permission for table "${blockedTable}"`,
);
throw new ForbiddenException(Messages.NO_READ_PERMISSION_FOR_TABLE(blockedTable));
}
}

Expand Down
251 changes: 141 additions & 110 deletions backend/src/entities/cedar-authorization/cedar-permissions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,33 +37,7 @@ export class CedarPermissionsService implements IUserAccessRepository {
async getUserConnectionAccessLevel(cognitoUserName: string, connectionId: string): Promise<AccessLevelEnum> {
const ctx = await this.loadContext(connectionId, cognitoUserName);
if (!ctx) return AccessLevelEnum.none;

const entities = buildCedarEntities(cognitoUserName, ctx.userGroups, connectionId);
if (
this.evaluatePolicies(
cognitoUserName,
CedarAction.ConnectionEdit,
CedarResourceType.Connection,
connectionId,
ctx.policies,
entities,
)
) {
return AccessLevelEnum.edit;
}
if (
this.evaluatePolicies(
cognitoUserName,
CedarAction.ConnectionRead,
CedarResourceType.Connection,
connectionId,
ctx.policies,
entities,
)
) {
return AccessLevelEnum.readonly;
}
return AccessLevelEnum.none;
return this.evaluateConnectionAccessLevel(cognitoUserName, connectionId, ctx);
}

async getUserConnectionAccessLevelsForMultipleConnections(
Expand All @@ -73,61 +47,14 @@ export class CedarPermissionsService implements IUserAccessRepository {
const result = new Map<string, AccessLevelEnum>();
if (connectionIds.length === 0) return result;

const allGroups = await this.globalDbContext.groupRepository.findAllUserGroupsInConnections(connectionIds, userId);

const groupsByConnection = new Map<string, Array<GroupEntity>>();
for (const group of allGroups) {
const connId = group.connection?.id;
if (!connId) continue;
let connectionGroups = groupsByConnection.get(connId);
if (!connectionGroups) {
connectionGroups = [];
groupsByConnection.set(connId, connectionGroups);
}
connectionGroups.push(group);
}

const contexts = await this.loadContexts(connectionIds, userId);
for (const connectionId of connectionIds) {
const userGroups = groupsByConnection.get(connectionId);
if (!userGroups || userGroups.length === 0) {
result.set(connectionId, AccessLevelEnum.none);
continue;
}

const policies = userGroups.map((g) => g.cedarPolicy).filter((policy): policy is string => Boolean(policy));
if (policies.length === 0) {
result.set(connectionId, AccessLevelEnum.none);
continue;
}

const entities = buildCedarEntities(userId, userGroups, connectionId);
if (
this.evaluatePolicies(
userId,
CedarAction.ConnectionEdit,
CedarResourceType.Connection,
connectionId,
policies,
entities,
)
) {
result.set(connectionId, AccessLevelEnum.edit);
} else if (
this.evaluatePolicies(
userId,
CedarAction.ConnectionRead,
CedarResourceType.Connection,
connectionId,
policies,
entities,
)
) {
result.set(connectionId, AccessLevelEnum.readonly);
} else {
result.set(connectionId, AccessLevelEnum.none);
}
const ctx = contexts.get(connectionId);
result.set(
connectionId,
ctx ? this.evaluateConnectionAccessLevel(userId, connectionId, ctx) : AccessLevelEnum.none,
);
}

return result;
}

Expand Down Expand Up @@ -175,34 +102,36 @@ export class CedarPermissionsService implements IUserAccessRepository {
const connectionId = await this.getConnectionId(groupId);
const ctx = await this.loadContext(connectionId, cognitoUserName);
if (!ctx) return AccessLevelEnum.none;
return this.evaluateGroupAccessLevel(cognitoUserName, connectionId, groupId, ctx);
}

const entities = buildCedarEntities(cognitoUserName, ctx.userGroups, connectionId);
const resourceId = groupId;
if (
this.evaluatePolicies(
cognitoUserName,
CedarAction.GroupEdit,
CedarResourceType.Group,
resourceId,
ctx.policies,
entities,
)
) {
return AccessLevelEnum.edit;
}
if (
this.evaluatePolicies(
cognitoUserName,
CedarAction.GroupRead,
CedarResourceType.Group,
resourceId,
ctx.policies,
entities,
)
) {
return AccessLevelEnum.readonly;
// Batched counterpart of getGroupAccessLevel: one group lookup for every connection the given
// groups belong to, instead of two queries per group. Each group must carry its `connection`
// relation (the repository finders that return groups for a user or a connection all join it).
async getGroupAccessLevels(userId: string, groups: Array<GroupEntity>): Promise<Map<string, AccessLevelEnum>> {
const result = new Map<string, AccessLevelEnum>();
if (groups.length === 0) return result;

const groupConnections = groups.map((group) => {
const connectionId = group.connection?.id;
if (!connectionId) {
throw new Error(`getGroupAccessLevels: group ${group.id} was loaded without its connection`);
}
return { groupId: group.id, connectionId };
});

const contexts = await this.loadContexts(
Array.from(new Set(groupConnections.map(({ connectionId }) => connectionId))),
userId,
);
for (const { groupId, connectionId } of groupConnections) {
const ctx = contexts.get(connectionId);
result.set(
groupId,
ctx ? this.evaluateGroupAccessLevel(userId, connectionId, groupId, ctx) : AccessLevelEnum.none,
);
}
return AccessLevelEnum.none;
return result;
}

async checkUserGroupRead(cognitoUserName: string, groupId: string): Promise<boolean> {
Expand Down Expand Up @@ -450,6 +379,31 @@ export class CedarPermissionsService implements IUserAccessRepository {
return canRead;
}

// Batched counterpart of improvedCheckTableRead: the user's groups are loaded once for the whole
// list instead of once per table. Cached verdicts are honoured and fresh ones are cached the same
// way, so the two stay interchangeable. Returns the subset of `tableNames` the user may query.
async getReadableTables(userId: string, connectionId: string, tableNames: Array<string>): Promise<Set<string>> {
const readable = new Set<string>();
const uncached: Array<string> = [];
for (const tableName of new Set(tableNames)) {
const cached = Cacher.getUserTableReadPermissionCache(userId, connectionId, tableName);
if (cached === null) {
uncached.push(tableName);
} else if (cached) {
readable.add(tableName);
}
}
if (uncached.length === 0) return readable;

const ctx = await this.loadContext(connectionId, userId);
for (const tableName of uncached) {
const canRead = ctx ? this.evaluateTableQuery(userId, connectionId, tableName, ctx) : false;
Cacher.setUserTableReadPermissionCache(userId, connectionId, tableName, canRead);
if (canRead) readable.add(tableName);
}
return readable;
}

private evaluateTablePermissions(
userId: string,
connectionId: string,
Expand Down Expand Up @@ -614,16 +568,93 @@ export class CedarPermissionsService implements IUserAccessRepository {
return false;
}

private async loadContext(connectionId: string, userId: string): Promise<EvalContext | null> {
const userGroups = await this.globalDbContext.groupRepository.findAllUserGroupsInConnection(connectionId, userId);
if (userGroups.length === 0) return null;
private evaluateConnectionAccessLevel(userId: string, connectionId: string, ctx: EvalContext): AccessLevelEnum {
const entities = buildCedarEntities(userId, ctx.userGroups, connectionId);
if (
this.evaluatePolicies(
userId,
CedarAction.ConnectionEdit,
CedarResourceType.Connection,
connectionId,
ctx.policies,
entities,
)
) {
return AccessLevelEnum.edit;
}
if (
this.evaluatePolicies(
userId,
CedarAction.ConnectionRead,
CedarResourceType.Connection,
connectionId,
ctx.policies,
entities,
)
) {
return AccessLevelEnum.readonly;
}
return AccessLevelEnum.none;
}

private evaluateGroupAccessLevel(
userId: string,
connectionId: string,
groupId: string,
ctx: EvalContext,
): AccessLevelEnum {
const entities = buildCedarEntities(userId, ctx.userGroups, connectionId);
if (
this.evaluatePolicies(userId, CedarAction.GroupEdit, CedarResourceType.Group, groupId, ctx.policies, entities)
) {
return AccessLevelEnum.edit;
}
if (
this.evaluatePolicies(userId, CedarAction.GroupRead, CedarResourceType.Group, groupId, ctx.policies, entities)
) {
return AccessLevelEnum.readonly;
}
return AccessLevelEnum.none;
}

private buildContext(userGroups: Array<GroupEntity>): EvalContext | null {
if (userGroups.length === 0) return null;
const policies = userGroups.map((g) => g.cedarPolicy).filter((policy): policy is string => Boolean(policy));
if (policies.length === 0) return null;

return { userGroups, policies };
}

private async loadContext(connectionId: string, userId: string): Promise<EvalContext | null> {
const userGroups = await this.globalDbContext.groupRepository.findAllUserGroupsInConnection(connectionId, userId);
return this.buildContext(userGroups);
}

// One query for several connections; connections where the user has no group (or no policy)
// are simply absent from the result.
private async loadContexts(connectionIds: Array<string>, userId: string): Promise<Map<string, EvalContext>> {
const contexts = new Map<string, EvalContext>();
if (connectionIds.length === 0) return contexts;

const allGroups = await this.globalDbContext.groupRepository.findAllUserGroupsInConnections(connectionIds, userId);
const groupsByConnection = new Map<string, Array<GroupEntity>>();
for (const group of allGroups) {
const connId = group.connection?.id;
if (!connId) continue;
let connectionGroups = groupsByConnection.get(connId);
if (!connectionGroups) {
connectionGroups = [];
groupsByConnection.set(connId, connectionGroups);
}
connectionGroups.push(group);
}

for (const [connectionId, userGroups] of groupsByConnection) {
const ctx = this.buildContext(userGroups);
if (ctx) contexts.set(connectionId, ctx);
}
return contexts;
}

// Public-access context: no groups, a single policy taken from the connection's
// public_cedar_policy. The synthetic principal is irrelevant since generated policies leave the
// principal unconstrained.
Expand Down
Loading
Loading