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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"@mikro-orm/core": "7.1.13",
"@mikro-orm/nestjs": "7.0.2",
"@mikro-orm/postgresql": "7.1.13",
"@monadiam/shared": "github:MonadIAM/shared#v2.0.17",
"@monadiam/shared": "github:MonadIAM/shared#v2.0.18",
"@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs/bullmq": "11.0.5",
"@nestjs/common": "11.2.1",
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ color: true
loglevel: info
savePrefix: ""
allowBuilds:
"@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/02519e05c5b6e06da0f0dd3cfa6d30aa2ca18519": true
"@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af": true
"@launchql/protobufjs": true
"msgpackr-extract": true
"unrs-resolver": true
Expand Down
3 changes: 0 additions & 3 deletions src/common/dictionaries/en/guard.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
"permission": {
"ACCOUNT_REQUIRED": "An authenticated account is required to perform this operation.",
"REALM_REQUIRED": "A realm is required to perform this operation.",
"REALM_SCOPE_MISSING": "The session does not carry a realm scope.",
"REALM_OUT_OF_SESSION_SCOPE": "The requested realm is outside the session's scope.",
"GLOBAL_SCOPE_REQUIRES_DIRECT_LOGIN": "Global permissions require a session established via a direct MonadIAM login.",
"INSUFFICIENT_PERMISSIONS": "Insufficient permissions to perform this operation."
},
"authn": {
Expand Down
3 changes: 0 additions & 3 deletions src/common/dictionaries/intl.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ export type I18nTranslations = {
"permission": {
"ACCOUNT_REQUIRED": string;
"REALM_REQUIRED": string;
"REALM_SCOPE_MISSING": string;
"REALM_OUT_OF_SESSION_SCOPE": string;
"GLOBAL_SCOPE_REQUIRES_DIRECT_LOGIN": string;
"INSUFFICIENT_PERMISSIONS": string;
};
"authn": {
Expand Down
3 changes: 0 additions & 3 deletions src/common/dictionaries/ru/guard.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
"permission": {
"ACCOUNT_REQUIRED": "Для выполнения операции требуется аутентифицированная учетная запись.",
"REALM_REQUIRED": "Для выполнения операции требуется указать realm.",
"REALM_SCOPE_MISSING": "Сессия не содержит область realm.",
"REALM_OUT_OF_SESSION_SCOPE": "Запрошенный realm вне области действия сессии.",
"GLOBAL_SCOPE_REQUIRES_DIRECT_LOGIN": "Глобальные права требуют сессии, полученной через прямой вход в MonadIAM.",
"INSUFFICIENT_PERMISSIONS": "Недостаточно прав для выполнения операции."
},
"authn": {
Expand Down
14 changes: 2 additions & 12 deletions src/context/infrastructure/guards/permission.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class PermissionGuard implements CanActivate {
}

const request = context.switchToHttp().getRequest<Req<{ Querystring: { realm?: string } }>>();
const realm = request.query?.realm ?? SYSTEM_REALM_ID;
const realm = globalPermissions ? SYSTEM_REALM_ID : (request.query?.realm ?? SYSTEM_REALM_ID);
const account = request.session?.account;
const realms = request.session?.realms;

Expand All @@ -54,18 +54,8 @@ export class PermissionGuard implements CanActivate {
throw Exception.forbidden({ messageKey: `${this.dictionaryPath}.REALM_REQUIRED` });
}

if (!realms?.length) {
throw Exception.forbidden({ messageKey: `${this.dictionaryPath}.REALM_SCOPE_MISSING` });
}

if (globalPermissions && !(realms.length === 1 && realms[0] === SYSTEM_REALM_ID)) {
throw Exception.forbidden({ messageKey: `${this.dictionaryPath}.GLOBAL_SCOPE_REQUIRES_DIRECT_LOGIN` });
} else if (!realms.includes(realm)) {
throw Exception.forbidden({ messageKey: `${this.dictionaryPath}.REALM_OUT_OF_SESSION_SCOPE` });
}

const matched = await this.accessCacheService.checkPermissions({
globalOnly: Boolean(globalPermissions),
globalOnly: Boolean(globalPermissions) || !realms?.includes(realm),
permissions,
account,
realm,
Expand Down
51 changes: 32 additions & 19 deletions src/context/infrastructure/guards/permission.guard.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe("[Guard] - Permission", () => {
});
});

it("[case] - requires realm scope in the session", async () => {
it("[case] - rejects a session without realm scope when no global grant matches", async () => {
// Arrange
const { guard } = helpers.permission();
const request = helpers.request({ session: { account: ACCOUNT_ID } });
Expand All @@ -97,12 +97,12 @@ describe("[Guard] - Permission", () => {

// Assert
await expect(result).rejects.toMatchObject({
message: "guard.permission.REALM_SCOPE_MISSING",
message: "guard.permission.INSUFFICIENT_PERMISSIONS",
statusCode: HttpStatus.FORBIDDEN,
});
});

it("[case] - rejects a realm outside the session scope", async () => {
it("[case] - rejects a realm outside the session scope when no global grant matches", async () => {
// Arrange
const { guard } = helpers.permission();
const request = helpers.request({
Expand All @@ -116,28 +116,41 @@ describe("[Guard] - Permission", () => {

// Assert
await expect(result).rejects.toMatchObject({
message: "guard.permission.REALM_OUT_OF_SESSION_SCOPE",
message: "guard.permission.INSUFFICIENT_PERMISSIONS",
statusCode: HttpStatus.FORBIDDEN,
});
});

it("[case] - requires a direct system login for global permissions", async () => {
// Arrange
const { guard } = helpers.permission();
const request = helpers.request({
session: { account: ACCOUNT_ID, realms: [SYSTEM_REALM_ID, REALM_ID] },
});
const context = helpers.context({ request, handlerMetadata: [[REQUIRE_GLOBAL_PERMISSION, [PERMISSION]]] });
it.each([
{ realms: [], globalOnly: false },
{ realms: [SYSTEM_REALM_ID], globalOnly: false },
{ realms: [SYSTEM_REALM_ID, REALM_ID], globalOnly: true },
])(
"[case] - permits a global grant independently of session realms ($globalOnly)",
async ({ realms, globalOnly }) => {
// Arrange
const { guard, checkPermissions } = helpers.permission({
matched: { [PERMISSION]: PrivilegeScope.GLOBAL },
});
const request = helpers.request({ query: { realm: REALM_ID }, session: { account: ACCOUNT_ID, realms } });
const metadata = globalOnly ? REQUIRE_GLOBAL_PERMISSION : REQUIRE_PERMISSION;
const context = helpers.context({ request, handlerMetadata: [[metadata, [PERMISSION]]] });

// Act
const result = guard.canActivate(context);
// Act
const result = await guard.canActivate(context);

// Assert
await expect(result).rejects.toMatchObject({
message: "guard.permission.GLOBAL_SCOPE_REQUIRES_DIRECT_LOGIN",
statusCode: HttpStatus.FORBIDDEN,
});
});
// Assert
expect(result).toBe(true);
expect(checkPermissions).toHaveBeenCalledTimes(1);
expect(checkPermissions).toHaveBeenCalledWith({
globalOnly: true,
permissions: [PERMISSION],
account: ACCOUNT_ID,
realm: globalOnly ? SYSTEM_REALM_ID : REALM_ID,
});
expect(request.metadata.permissions).toEqual({ [PERMISSION]: PrivilegeScope.GLOBAL });
},
);

it("[case] - rejects an account without a matched permission", async () => {
// Arrange
Expand Down
148 changes: 98 additions & 50 deletions src/context/infrastructure/services/access-cache.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PermissionCode, PrivilegeScope } from "@monadiam/shared";
import { PermissionCode, PrivilegeScope, SYSTEM_REALM_ID } from "@monadiam/shared";
import { Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import ms, { StringValue } from "ms";
Expand All @@ -10,7 +10,7 @@ import { AccessControlClient } from "~infrastructure/grpc";
@Injectable()
export class AccessCacheService implements InfrastructureServices.AccessCache.Contract {
private readonly ttl: number;
private readonly namespace = "access-cache";
private readonly namespace = "access-cache:v2";

public constructor(
@Inject(REDIS_CACHE_CLIENT)
Expand All @@ -25,55 +25,51 @@ export class AccessCacheService implements InfrastructureServices.AccessCache.Co
props: InfrastructureServices.AccessCache.CheckPermissions.Props,
): InfrastructureServices.AccessCache.CheckPermissions.Result {
const { account, realm, permissions, globalOnly } = props;
const key = await this.resolveVersionedKey({ account, realm });
const loadedKey = `${key}:loaded`;
const pipeline = this.redis.pipeline();
if (!permissions.length) {
return {};
}

pipeline.exists(loadedKey);
const keys = await this.resolveVersionedKeys({ account, realm });
const pipeline = this.redis.pipeline();
pipeline.exists(`${keys.global}:loaded`);
pipeline.hmget(keys.global, ...permissions);

for (const permission of permissions) {
pipeline.hget(key, permission);
if (!globalOnly) {
pipeline.exists(`${keys.realm}:loaded`);
pipeline.hmget(keys.realm, ...permissions);
}

pipeline.expire(key, this.ttl);
pipeline.expire(loadedKey, this.ttl);

const results = await pipeline.exec();
const isLoaded = results?.[0]?.[1] === 1;
for (const [error] of results ?? []) {
if (error) {
throw error;
}
}

if (isLoaded) {
const checks = results?.slice(1, 1 + permissions.length) ?? [];
const globalLoaded = results?.[0]?.[1] === 1;
const realmLoaded = results?.[2]?.[1] === 1;
const matched: Partial<Record<PermissionCode, PrivilegeScope>> = {};

const matched: Partial<Record<PermissionCode, PrivilegeScope>> = {};
if (globalLoaded && (globalOnly || realmLoaded)) {
const global = results?.[1]?.[1] as Nullable<string>[];
const local = results?.[3]?.[1] as Nullable<string>[];

for (const [index, permission] of permissions.entries()) {
const scope = checks[index]?.[1];
if (scope === PrivilegeScope.GLOBAL || (!globalOnly && scope === PrivilegeScope.REALM)) {
matched[permission] = scope;
if (!globalOnly && local[index] === PrivilegeScope.REALM) {
matched[permission] = PrivilegeScope.REALM;
} else if (global[index] === PrivilegeScope.GLOBAL) {
matched[permission] = PrivilegeScope.GLOBAL;
}
}

return matched;
}

const effectivePermissions = await this.accessControlClient.listEffectivePrivileges({ account, realm });
const cachePipeline = this.redis.pipeline();

if (Object.keys(effectivePermissions).length) {
cachePipeline.hset(key, effectivePermissions);
cachePipeline.expire(key, this.ttl);
}

cachePipeline.set(loadedKey, "1", "EX", this.ttl);

await cachePipeline.exec();

const matched: Partial<Record<PermissionCode, PrivilegeScope>> = {};

for (const permission of permissions) {
const scope = effectivePermissions[permission];
if (scope === PrivilegeScope.GLOBAL || (!globalOnly && scope === PrivilegeScope.REALM)) {
matched[permission] = scope;
} else {
const snapshot = await this.loadPermissions({ account, realm, globalOnly, keys, globalLoaded, realmLoaded });

for (const permission of permissions) {
if (!globalOnly && snapshot.privileges[permission] === PrivilegeScope.REALM) {
matched[permission] = PrivilegeScope.REALM;
} else if (snapshot.global[permission] === PrivilegeScope.GLOBAL) {
matched[permission] = PrivilegeScope.GLOBAL;
}
}
}

Expand All @@ -84,9 +80,7 @@ export class AccessCacheService implements InfrastructureServices.AccessCache.Co
props: InfrastructureServices.AccessCache.Delete.Props,
): InfrastructureServices.AccessCache.Delete.Result {
const { account, realm } = props;
const key = await this.resolveVersionedKey({ account, realm });

await this.redis.unlink(key, `${key}:loaded`);
await this.redis.incr(`${this.namespace}:account-realm-version:${account}:${realm}`);
}

public async deleteAccount(
Expand All @@ -107,17 +101,71 @@ export class AccessCacheService implements InfrastructureServices.AccessCache.Co
await this.redis.incr(`${this.namespace}:global-version`);
}

public async resolveVersionedKey(
props: InfrastructureServices.AccessCache.ResolveVersionedKey.Props,
): InfrastructureServices.AccessCache.ResolveVersionedKey.Result {
public async loadPermissions(
props: InfrastructureServices.AccessCache.LoadPermissions.Props,
): InfrastructureServices.AccessCache.LoadPermissions.Result {
const { account, realm, globalOnly, keys, globalLoaded, realmLoaded } = props;
const snapshot = await this.accessControlClient.listEffectivePrivileges({
realm: globalOnly ? SYSTEM_REALM_ID : realm,
account,
});

const transaction = this.redis.multi();

if (!globalLoaded) {
transaction.del(keys.global, `${keys.global}:loaded`);
if (Object.keys(snapshot.global).length) {
transaction.hset(keys.global, snapshot.global);
transaction.expire(keys.global, this.ttl);
}
transaction.set(`${keys.global}:loaded`, "1", "EX", this.ttl);
}

if (!globalOnly && !realmLoaded) {
transaction.del(keys.realm, `${keys.realm}:loaded`);
if (Object.keys(snapshot.privileges).length) {
transaction.hset(keys.realm, snapshot.privileges);
transaction.expire(keys.realm, this.ttl);
}
transaction.set(`${keys.realm}:loaded`, "1", "EX", this.ttl);
}

const written = await transaction.exec();
for (const [error] of written ?? []) {
if (error) {
throw error;
}
}

return snapshot;
}

public async resolveVersionedKeys(
props: InfrastructureServices.AccessCache.ResolveVersionedKeys.Props,
): InfrastructureServices.AccessCache.ResolveVersionedKeys.Result {
const { account, realm } = props;
const globalVersionKey = `${this.namespace}:global-version`;
const accountVersionKey = `${this.namespace}:account-version:${account}`;
const realmVersionKey = `${this.namespace}:realm-version:${realm}`;

const versions = await this.redis.mget(globalVersionKey, accountVersionKey, realmVersionKey);
const [globalVersion, accountVersion, realmVersion] = versions.map((version) => version ?? "0");

return `${this.namespace}:${globalVersion}:${account}:${accountVersion}:${realm}:${realmVersion}`;
const systemVersionKey = `${this.namespace}:realm-version:${SYSTEM_REALM_ID}`;
const accountRealmVersionKey = `${this.namespace}:account-realm-version:${account}:${realm}`;
const accountSystemVersionKey = `${this.namespace}:account-realm-version:${account}:${SYSTEM_REALM_ID}`;
const versions = await this.redis.mget(
globalVersionKey,
accountVersionKey,
realmVersionKey,
systemVersionKey,
accountRealmVersionKey,
accountSystemVersionKey,
);
const [globalVersion, accountVersion, realmVersion, systemVersion, accountRealmVersion, accountSystemVersion] =
versions.map((version) => version ?? "0");
const prefix = `${this.namespace}:${globalVersion}:${account}:${accountVersion}`;

return {
realm: `${prefix}:${realm}:${realmVersion}:${accountRealmVersion}`,
global: `${prefix}:${SYSTEM_REALM_ID}:${systemVersion}:${accountSystemVersion}:global`,
};
}
}
Loading
Loading