diff --git a/package.json b/package.json index 0bc657e..23527bb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05cc3b6..2d6594a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: specifier: 7.1.13 version: 7.1.13(@mikro-orm/core@7.1.13) '@monadiam/shared': - specifier: github:MonadIAM/shared#v2.0.17 - version: https://codeload.github.com/MonadIAM/shared/tar.gz/02519e05c5b6e06da0f0dd3cfa6d30aa2ca18519 + specifier: github:MonadIAM/shared#v2.0.18 + version: https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af '@nest-lab/throttler-storage-redis': specifier: 1.2.0 version: 1.2.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.1)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2))(ioredis@6.0.0(supports-color@8.1.1))(reflect-metadata@0.2.2) @@ -1106,9 +1106,9 @@ packages: engines: {node: '>=22.0.0'} hasBin: true - '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/02519e05c5b6e06da0f0dd3cfa6d30aa2ca18519': - resolution: {gitHosted: true, tarball: https://codeload.github.com/MonadIAM/shared/tar.gz/02519e05c5b6e06da0f0dd3cfa6d30aa2ca18519} - version: 2.0.17 + '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af': + resolution: {gitHosted: true, tarball: https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af} + version: 2.0.18 engines: {node: 24.18.0} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': @@ -6088,7 +6088,7 @@ snapshots: uuid: 11.1.1 zod: 3.25.76 - '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/02519e05c5b6e06da0f0dd3cfa6d30aa2ca18519': {} + '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/87c7b77806041337becc7027619cf0372cb4d8af': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c06fe1e..9ba9e4b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/common/dictionaries/en/guard.json b/src/common/dictionaries/en/guard.json index f0f387b..3a3a71d 100644 --- a/src/common/dictionaries/en/guard.json +++ b/src/common/dictionaries/en/guard.json @@ -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": { diff --git a/src/common/dictionaries/intl.generated.ts b/src/common/dictionaries/intl.generated.ts index e84362b..6dbd656 100644 --- a/src/common/dictionaries/intl.generated.ts +++ b/src/common/dictionaries/intl.generated.ts @@ -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": { diff --git a/src/common/dictionaries/ru/guard.json b/src/common/dictionaries/ru/guard.json index c34c197..4e128a1 100644 --- a/src/common/dictionaries/ru/guard.json +++ b/src/common/dictionaries/ru/guard.json @@ -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": { diff --git a/src/context/infrastructure/guards/permission.guard.ts b/src/context/infrastructure/guards/permission.guard.ts index fe79fef..52fc5f1 100644 --- a/src/context/infrastructure/guards/permission.guard.ts +++ b/src/context/infrastructure/guards/permission.guard.ts @@ -42,7 +42,7 @@ export class PermissionGuard implements CanActivate { } const request = context.switchToHttp().getRequest>(); - 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; @@ -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, diff --git a/src/context/infrastructure/guards/permission.guard.unit.spec.ts b/src/context/infrastructure/guards/permission.guard.unit.spec.ts index d82a366..e79fe3f 100644 --- a/src/context/infrastructure/guards/permission.guard.unit.spec.ts +++ b/src/context/infrastructure/guards/permission.guard.unit.spec.ts @@ -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 } }); @@ -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({ @@ -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 diff --git a/src/context/infrastructure/services/access-cache.service.ts b/src/context/infrastructure/services/access-cache.service.ts index 7cbc96c..35190d2 100644 --- a/src/context/infrastructure/services/access-cache.service.ts +++ b/src/context/infrastructure/services/access-cache.service.ts @@ -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"; @@ -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) @@ -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> = {}; - const matched: Partial> = {}; + if (globalLoaded && (globalOnly || realmLoaded)) { + const global = results?.[1]?.[1] as Nullable[]; + const local = results?.[3]?.[1] as Nullable[]; 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> = {}; - - 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; + } } } @@ -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( @@ -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`, + }; } } diff --git a/src/context/infrastructure/services/types.d.ts b/src/context/infrastructure/services/types.d.ts index cccb01d..7ab8d17 100644 --- a/src/context/infrastructure/services/types.d.ts +++ b/src/context/infrastructure/services/types.d.ts @@ -77,16 +77,29 @@ declare global { interface Contract extends InternalContract, PublicContract {} interface InternalContract { - resolveVersionedKey: ResolveVersionedKey.Signature; + resolveVersionedKeys: ResolveVersionedKeys.Signature; + loadPermissions: LoadPermissions.Signature; } - namespace ResolveVersionedKey { + namespace LoadPermissions { + type Props = Omit & { + keys: Awaited; + globalLoaded: boolean; + realmLoaded: boolean; + }; + + type Result = GRPC.AccessControl.Client.Response; + + type Signature = (props: Props) => Result; + } + + namespace ResolveVersionedKeys { type Props = { account: string; realm: string; }; - type Result = Promise; + type Result = Promise<{ realm: string; global: string }>; type Signature = (props: Props) => Result; } diff --git a/src/infrastructure/grpc/access-control.client.ts b/src/infrastructure/grpc/access-control.client.ts index 8073600..9004cf7 100644 --- a/src/infrastructure/grpc/access-control.client.ts +++ b/src/infrastructure/grpc/access-control.client.ts @@ -19,7 +19,7 @@ export class AccessControlClient implements GRPC.AccessControl.Client.Contract, public async listEffectivePrivileges(request: GRPC.AccessControl.Client.Request): GRPC.AccessControl.Client.Response { const response = this.service.listEffectivePrivileges(request); - const { privileges } = await firstValueFrom(response); - return privileges ?? {}; + const { privileges, global } = await firstValueFrom(response); + return { privileges: privileges ?? {}, global: global ?? {} }; } } diff --git a/src/infrastructure/grpc/types.d.ts b/src/infrastructure/grpc/types.d.ts index 3e91d20..f54026f 100644 --- a/src/infrastructure/grpc/types.d.ts +++ b/src/infrastructure/grpc/types.d.ts @@ -18,7 +18,7 @@ declare global { } type Request = GRPC.AccessControl.ListEffectivePrivileges.Request; - type Response = Promise; + type Response = Promise; } } }