diff --git a/gen/index.js b/gen/index.js index dda28aa..3904b18 100644 --- a/gen/index.js +++ b/gen/index.js @@ -1,4 +1,10 @@ import { storeGetStructure } from "@compas/store"; +import { featureFlagDefinition, permissions } from "../src/constants.js"; +import { extendWithAuthCustom } from "./auth.js"; +import { extendWithDatabase } from "./database.js"; +import { extendWithMail } from "./mail.js"; +import { extendWithScaffold } from "./scaffold.js"; +import { extendWithType } from "./type.js"; import { authPermissions, extendWithAuthAnonymousBased, @@ -9,12 +15,6 @@ import { extendWithFeatureFlag, extendWithManagement, } from "@lightbasenl/backend"; -import { featureFlagDefinition, permissions } from "../src/constants.js"; -import { extendWithAuthCustom } from "./auth.js"; -import { extendWithDatabase } from "./database.js"; -import { extendWithMail } from "./mail.js"; -import { extendWithScaffold } from "./scaffold.js"; -import { extendWithType } from "./type.js"; /** * Extend with compas additional/optional package structures diff --git a/package-lock.json b/package-lock.json index 139c614..f7c0180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1990,9 +1990,9 @@ } }, "node_modules/@lightbase/pull-through-cache": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@lightbase/pull-through-cache/-/pull-through-cache-0.1.2.tgz", - "integrity": "sha512-nTosjLM02B/I30CO60u7EvIc+k0DGqxW5uCMzI58zZ4UjgjG2/RDjdoscOFkTizZ8pK+qKgE1zOWaMdjNVZwsQ==" + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@lightbase/pull-through-cache/-/pull-through-cache-0.2.1.tgz", + "integrity": "sha512-2FtndHp4ywU7VHTFsIhYzsmbdLBmX5Yo2uokiPMNdWswvc0Xp0kTB50lJEeAuXgYoNtUuV66nSeObqkH6C79rg==" }, "node_modules/@lightbasenl/backend": { "resolved": "vendor/backend", @@ -7552,9 +7552,9 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, "node_modules/rate-limiter-flexible": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.3.tgz", - "integrity": "sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==" + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.4.tgz", + "integrity": "sha512-ftYHrIfSqWYDIJZ4yPTrgOduByAp+86gUS9iklv0JoXVM8eQCAjTnydCj1hAT4MmhmkSw86NaFEJ28m/LC1pKA==" }, "node_modules/raw-body": { "version": "2.5.2", @@ -9019,10 +9019,10 @@ "name": "@lightbasenl/backend", "version": "0.50.2", "dependencies": { - "@lightbase/pull-through-cache": "0.1.2", - "@xmldom/xmldom": "0.8.10", + "@lightbase/pull-through-cache": "0.2.1", + "@xmldom/xmldom": "0.9.4", "bcrypt": "5.1.1", - "rate-limiter-flexible": "5.0.3", + "rate-limiter-flexible": "5.0.4", "speakeasy": "2.0.0", "xml-crypto": "6.0.0", "xpath": "0.0.34" @@ -9032,6 +9032,15 @@ "@compas/store": "*" } }, + "vendor/backend/node_modules/@xmldom/xmldom": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.4.tgz", + "integrity": "sha512-zglELfWx7g1cEpVMRBZ0srIQO5nEvKvraJ6CVUC/c5Ky1GgX8OIjtUj5qOweTYULYZo5VnXs/LpUUUNiGpX/rA==", + "deprecated": "this version has critical issues, please update to the latest version", + "engines": { + "node": ">=14.6" + } + }, "vendor/backend/node_modules/xpath": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", diff --git a/scripts/queue.js b/scripts/queue.js index 5b71432..62fdb0c 100644 --- a/scripts/queue.js +++ b/scripts/queue.js @@ -10,12 +10,6 @@ import { queueWorkerCreate, queueWorkerRegisterCronJobs, } from "@compas/store"; -import { - authEventNames, - authJobNames, - authPasswordBasedInvalidateResetTokens, - managementInvalidateUsers, -} from "@lightbasenl/backend"; import { authAnonymousBasedUserRegisteredEvent, authPasswordBasedEmailUpdatedEvent, @@ -29,6 +23,12 @@ import { injectServices } from "../src/service.js"; import { serviceLogger } from "../src/services/logger.js"; import { sql } from "../src/services/postgres.js"; import { bucketName, s3Client } from "../src/services/s3.js"; +import { + authEventNames, + authJobNames, + authPasswordBasedInvalidateResetTokens, + managementInvalidateUsers, +} from "@lightbasenl/backend"; mainFn(import.meta, main); diff --git a/src/auth/jobs.js b/src/auth/jobs.js index 66256d5..bb74198 100644 --- a/src/auth/jobs.js +++ b/src/auth/jobs.js @@ -36,7 +36,7 @@ export async function authAnonymousBasedUserRegisteredEvent( } // TODO(platform): Act - // eslint-disable-next-line no-unused-vars + const x = 5; eventStop(event); @@ -87,7 +87,7 @@ export async function authPasswordBasedUserRegisteredEvent( } // TODO(platform): Act - // eslint-disable-next-line no-unused-vars + const x = 5; eventStop(event); @@ -139,7 +139,7 @@ export async function authPasswordBasedForgotPasswordEvent( } // TODO(platform): Act - // eslint-disable-next-line no-unused-vars + const x = 5; eventStop(event); @@ -180,7 +180,7 @@ export async function authPasswordBasedPasswordUpdatedEvent( } // TODO(platform): Act - // eslint-disable-next-line no-unused-vars + const x = 5; eventStop(event); @@ -228,7 +228,7 @@ export async function authPasswordBasedEmailUpdatedEvent(event, sql, { data }) { } // TODO(platform): Act - // eslint-disable-next-line no-unused-vars + const x = 5; eventStop(event); @@ -269,7 +269,7 @@ export async function authPasswordBasedLoginVerifiedEvent( } // TODO(platform): Act - // eslint-disable-next-line no-unused-vars + const x = 5; eventStop(event); @@ -310,7 +310,7 @@ export async function authPasswordBasedPasswordResetEvent( } // TODO(platform): Act - // eslint-disable-next-line no-unused-vars + const x = 5; eventStop(event); diff --git a/src/mail/events.js b/src/mail/events.js index c3b6d75..7090c5b 100644 --- a/src/mail/events.js +++ b/src/mail/events.js @@ -42,7 +42,7 @@ export async function mailSendGeneric(event, email, payload) { * @param {function(MailAddressHeaders, T): MailTemplateResponse} template * @param {MailAddressHeaders} addresses * @param {T} [payload={}] - * @param {import("@types/nodemailer").Attachment[]} [attachments] + * @param {Array} [attachments] * @returns {Promise} */ async function mailSend( @@ -92,9 +92,9 @@ async function mailSend( * Construct a mail header object for nodemailer * * @param {MailAddress} from - * @param {MailAddress|MailAddress[]} to - * @param {MailAddress[]|undefined} [cc] - * @param {MailAddress[]|undefined} [bcc] + * @param {MailAddress | Array} to + * @param {Array | undefined} [cc] + * @param {Array | undefined} [bcc] * @returns {MailAddressHeaders} */ function constructMailAddressHeaders(from, to, cc, bcc) { diff --git a/src/scaffold/controller.js b/src/scaffold/controller.js index 7c9ebe0..ccda070 100644 --- a/src/scaffold/controller.js +++ b/src/scaffold/controller.js @@ -1,7 +1,7 @@ import { newEventFromEvent } from "@compas/stdlib"; -import { authCreateUser, multitenantRequireTenant } from "@lightbasenl/backend"; import { scaffoldHandlers } from "../generated/application/scaffold/controller.js"; import { sql } from "../services/postgres.js"; +import { authCreateUser, multitenantRequireTenant } from "@lightbasenl/backend"; // TODO(platform): remove this; scaffoldHandlers.createUser = async (ctx) => { diff --git a/src/services/app.js b/src/services/app.js index 218b17c..ab58366 100644 --- a/src/services/app.js +++ b/src/services/app.js @@ -1,7 +1,7 @@ import { createBodyParser, getApp } from "@compas/server"; -import { backendGetConfig } from "@lightbasenl/backend"; import { router } from "../generated/application/common/router.js"; import { serviceLogger } from "./logger.js"; +import { backendGetConfig } from "@lightbasenl/backend"; /** * @type {Application} diff --git a/src/services/core.js b/src/services/core.js index 21d43fd..81d978b 100644 --- a/src/services/core.js +++ b/src/services/core.js @@ -5,7 +5,7 @@ import { AppError, environment } from "@compas/stdlib"; * This method should be used as guard during startup to ensure non-unexpected * code paths are hit during runtime. * - * @param {string[]} requiredEnvironmentVariables + * @param {Array} requiredEnvironmentVariables * @returns {void} */ export function ensureEnvironmentVars(requiredEnvironmentVariables) { diff --git a/src/services/lpc.js b/src/services/lpc.js index 2d085da..c461fed 100644 --- a/src/services/lpc.js +++ b/src/services/lpc.js @@ -1,11 +1,11 @@ import { newEvent } from "@compas/stdlib"; +import { buildMandatoryRoles, permissions } from "../constants.js"; +import { serviceLogger } from "./logger.js"; import { authPermissions, backendInit, backendInitServices, } from "@lightbasenl/backend"; -import { buildMandatoryRoles, permissions } from "../constants.js"; -import { serviceLogger } from "./logger.js"; /** * @returns {Promise} diff --git a/src/testing.js b/src/testing.js index 7751711..7ca78b7 100644 --- a/src/testing.js +++ b/src/testing.js @@ -4,10 +4,6 @@ import { cleanupTestPostgresDatabase, objectStorageRemoveBucket, } from "@compas/store"; -import { - authInjectTokenInterceptors, - multitenantInjectAxios, -} from "@lightbasenl/backend"; import axios from "axios"; import { axiosInterceptErrorAndWrapWithAppError } from "./generated/application/common/api-client.js"; import { @@ -25,6 +21,10 @@ import { serviceS3EnsureBuckets, serviceS3Init, } from "./services/s3.js"; +import { + authInjectTokenInterceptors, + multitenantInjectAxios, +} from "@lightbasenl/backend"; /** * Initialize all services based on an empty database and fresh s3 bucket. diff --git a/vendor/backend/README.md b/vendor/backend/README.md index 1f529cd..c58624e 100644 --- a/vendor/backend/README.md +++ b/vendor/backend/README.md @@ -200,6 +200,7 @@ erDiagram string description string name generic tenantValues + generic userValues date createdAt date updatedAt } diff --git a/vendor/backend/package.json b/vendor/backend/package.json index cfb7cde..f8a66f9 100644 --- a/vendor/backend/package.json +++ b/vendor/backend/package.json @@ -13,10 +13,10 @@ ], "scripts": {}, "dependencies": { - "@lightbase/pull-through-cache": "0.1.2", - "@xmldom/xmldom": "0.8.10", + "@lightbase/pull-through-cache": "0.2.1", + "@xmldom/xmldom": "0.9.4", "bcrypt": "5.1.1", - "rate-limiter-flexible": "5.0.3", + "rate-limiter-flexible": "5.0.4", "speakeasy": "2.0.0", "xml-crypto": "6.0.0", "xpath": "0.0.34" @@ -30,5 +30,5 @@ "url": "https://github.com/lightbasenl/platform-components.git", "directory": "packages/backend" }, - "gitHead": "b335a6c8aa6f5e14489b582b02b6fa45beda00b6" + "gitHead": "822c961fc96b2d0682a98b124bf83722f8e906c1" } diff --git a/vendor/backend/src/auth/digid-based/events.js b/vendor/backend/src/auth/digid-based/events.js index 51a9987..55c6be8 100644 --- a/vendor/backend/src/auth/digid-based/events.js +++ b/vendor/backend/src/auth/digid-based/events.js @@ -15,7 +15,7 @@ import { uuid, } from "@compas/stdlib"; import { queueWorkerAddJob } from "@compas/store"; -import xmldom from "@xmldom/xmldom"; +import xmldom, { MIME_TYPE } from "@xmldom/xmldom"; import axios from "axios"; import xmlCrypto from "xml-crypto"; import xpath from "xpath"; @@ -362,7 +362,10 @@ export async function authDigidBasedResolveArtifact( ); } - const doc = new xmldom.DOMParser().parseFromString(xmlResponse); + const doc = new xmldom.DOMParser().parseFromString( + xmlResponse, + MIME_TYPE.XML_APPLICATION, + ); const [mainStatus, subStatus, subSubStatus] = xpath.select( "//*[local-name(.)='StatusCode']/@Value", doc, @@ -534,7 +537,10 @@ async function authDigidBasedGetSignatureForPayload( async function authDigidBasedVerifySignaturesForXmlPayload(event, payload) { eventStart(event, "authDigidBased.verifySignaturesForXmlPayload"); - const doc = new xmldom.DOMParser().parseFromString(payload); + const doc = new xmldom.DOMParser().parseFromString( + payload, + MIME_TYPE.XML_APPLICATION, + ); const signatures = xpath.select("//*[local-name(.)='Signature']", doc); if (signatures.length === 0) { throw AppError.serverError({ diff --git a/vendor/backend/src/auth/events.js b/vendor/backend/src/auth/events.js index e03a16b..ca37ba3 100644 --- a/vendor/backend/src/auth/events.js +++ b/vendor/backend/src/auth/events.js @@ -104,7 +104,7 @@ function authVerifyServerSideRenderingHeader(ctx, headerKey) { * }; * ``` * @param {import("@compas/server").Context} ctx - * @param {string[]} allowedIps + * @param {Array} allowedIps */ export function authIpCheck(ctx, allowedIps) { let trustedIp = ctx.ip; diff --git a/vendor/backend/src/auth/keycloak-based/events.js b/vendor/backend/src/auth/keycloak-based/events.js index 08cfa18..db76572 100644 --- a/vendor/backend/src/auth/keycloak-based/events.js +++ b/vendor/backend/src/auth/keycloak-based/events.js @@ -408,6 +408,8 @@ export async function authKeycloakBasedVerifyAndReadToken( } catch (e) { throw AppError.validationError( "authKeycloakBased.verifyAndReadToken.invalidToken", + {}, + e, ); } } diff --git a/vendor/backend/src/auth/permissions/controller.js b/vendor/backend/src/auth/permissions/controller.js index f493a48..dfe0f9d 100644 --- a/vendor/backend/src/auth/permissions/controller.js +++ b/vendor/backend/src/auth/permissions/controller.js @@ -1,3 +1,4 @@ +/* eslint-disable jsdoc/check-types */ import { newEventFromEvent } from "@compas/stdlib"; import { backendGetTenantAndUser } from "../../events.js"; import { sql } from "../../services.js"; @@ -19,8 +20,8 @@ import { /** * @typedef {(tenants: - * QueryResultBackendTenant[]) => - * PermissionMandatoryRole[]} PermissionBuildMandatoryRoles + * Array) => + * Array} PermissionBuildMandatoryRoles */ /** diff --git a/vendor/backend/src/auth/permissions/events.js b/vendor/backend/src/auth/permissions/events.js index 1c9e54c..081dd5e 100644 --- a/vendor/backend/src/auth/permissions/events.js +++ b/vendor/backend/src/auth/permissions/events.js @@ -12,7 +12,7 @@ import { * @param {import("@compas/stdlib").InsightEvent} event * @param {import("@compas/store").Postgres} sql * @param {QueryResultBackendTenant} tenant - * @param {string[]} staticRoleIds + * @param {Array} staticRoleIds * @param {string|{ role: string}} roleObjectOrId * @returns {Promise} */ @@ -62,7 +62,7 @@ export async function authPermissionRequireRole( * * @param {import("@compas/stdlib").InsightEvent} event * @param {import("@compas/store").Postgres} sql - * @param {string[]} permissions + * @param {Array} permissions * @returns {Promise} */ export async function authPermissionSyncPermissions(event, sql, permissions) { @@ -110,8 +110,8 @@ export async function authPermissionSyncPermissions(event, sql, permissions) { * * @param {import("@compas/stdlib").InsightEvent} event * @param {import("@compas/store").Postgres} sql - * @param {PermissionMandatoryRole[]} mandatoryRoles - * @returns {Promise<{ staticRoleIds: string[] }>} + * @param {Array} mandatoryRoles + * @returns {Promise<{staticRoleIds: Array}>} */ export async function authPermissionSyncMandatoryRoles( event, @@ -120,9 +120,9 @@ export async function authPermissionSyncMandatoryRoles( ) { eventStart(event, "authPermission.syncMandatoryRoles"); - /** @type {PermissionMandatoryRole[]} */ + /** @type {Array} */ const globalRoles = []; - /** @type {Record} */ + /** @type {Record>} */ const byTenant = {}; for (const role of mandatoryRoles) { @@ -251,7 +251,7 @@ export async function authPermissionPermissionList(event, sql) { * @param {import("@compas/stdlib").InsightEvent} event * @param {import("@compas/store").Postgres} sql * @param {QueryResultBackendTenant} tenant - * @param {string[]} staticRoleIds + * @param {Array} staticRoleIds * @returns {Promise} */ export async function authPermissionRoleList( @@ -558,8 +558,8 @@ export async function authPermissionUserRemoveRole(event, sql, user, body) { /** * @typedef {object} AuthPermissionUserSyncRolesOptions - * @property {string[]|undefined} [idIn] - * @property {string[]|undefined} [identifierIn] + * @property {Array | undefined} [idIn] + * @property {Array | undefined} [identifierIn] */ /** diff --git a/vendor/backend/src/auth/user.events.js b/vendor/backend/src/auth/user.events.js index 7b78005..b27ef7f 100644 --- a/vendor/backend/src/auth/user.events.js +++ b/vendor/backend/src/auth/user.events.js @@ -1,3 +1,5 @@ +/* eslint-disable jsdoc/check-types */ + import { AppError, eventStart, @@ -10,6 +12,7 @@ import { import { query, queueWorkerAddJob } from "@compas/store"; import speakeasy from "speakeasy"; import { + onAuthRequireUserCallback, queries, queryPermission, queryRole, @@ -89,7 +92,10 @@ const authQueries = { * @property {boolean|undefined} [requireDigidBased] * @property {boolean|undefined} [requireKeycloakBased] * @property {boolean|undefined} [requirePasswordBased] - * @property {AuthPermissionIdentifier[]|undefined} [requiredPermissions] + * @property {AuthPermissionIdentifier[]|undefined} [requiredPermissions] Require all + * provided permissions + * @property {AuthPermissionIdentifier[]|undefined} [oneOfRequiredPermissions] Require + * one of the provided permissions */ /** @@ -271,12 +277,12 @@ export async function authCreateUser(event, sql, data, options) { * isVerified?: boolean, * }, * withPermissions?: { - * permissions?: AuthPermissionIdentifier[], - * roles?: string[], + * permissions?: Array, + * roles?: Array, * }, * withMultitenant?: { * syncUsersAcrossAllTenants?: boolean, - * tenants?: string[], + * tenants?: Array, * } * }} options * @returns {Promise} @@ -633,8 +639,8 @@ export async function authRequireUser( } if ( - Array.isArray(options.requiredPermissions) && - options.requiredPermissions.length > 0 + Array.isArray(options.requiredPermissions) || + Array.isArray(options.oneOfRequiredPermissions) ) { const permissionSet = new Set(); // @ts-expect-error @@ -645,20 +651,38 @@ export async function authRequireUser( } } - const missingPermissions = []; - for (const requiredPermission of options.requiredPermissions) { - if (!permissionSet.has(requiredPermission)) { - missingPermissions.push(requiredPermission); + if (options.requiredPermissions) { + const missingPermissions = []; + for (const requiredPermission of options.requiredPermissions) { + if (!permissionSet.has(requiredPermission)) { + missingPermissions.push(requiredPermission); + } } - } - if (missingPermissions.length > 0) { - throw AppError.validationError(`${eventKey}.missingPermissions`, { - missingPermissions, - }); + if (missingPermissions.length > 0) { + throw AppError.validationError(`${eventKey}.missingPermissions`, { + missingPermissions, + }); + } + } else if (options.oneOfRequiredPermissions) { + let hasOnePermission = false; + for (const requiredPermission of options.oneOfRequiredPermissions) { + if (permissionSet.has(requiredPermission)) { + hasOnePermission = true; + break; + } + } + + if (!hasOnePermission) { + throw AppError.validationError(`${eventKey}.missingPermissions`, { + missingPermissions: options.oneOfRequiredPermissions, + }); + } } } + onAuthRequireUserCallback(user); + eventStop(event); return user; diff --git a/vendor/backend/src/constants.js b/vendor/backend/src/constants.js index 73444ea..c3b4bb8 100644 --- a/vendor/backend/src/constants.js +++ b/vendor/backend/src/constants.js @@ -1,6 +1,6 @@ /** * LPC Internal feature flags * - * @type {string[]} + * @type {Array} */ export const lpcInternalFeatureFlags = []; diff --git a/vendor/backend/src/feature-flag/cache.js b/vendor/backend/src/feature-flag/cache.js index 4089824..67d1579 100644 --- a/vendor/backend/src/feature-flag/cache.js +++ b/vendor/backend/src/feature-flag/cache.js @@ -1,6 +1,7 @@ import { AppError } from "@compas/stdlib"; import { PullThroughCache } from "@lightbase/pull-through-cache"; import { queryFeatureFlag, sql } from "../services.js"; +import { cacheEventToSentryMetric } from "../util.js"; /** * Short TTL feature flag cache. Keeps all flags for 5 seconds in memory, @@ -16,6 +17,9 @@ export const featureFlagCache = new PullThroughCache() }) .withFetcher({ fetcher: featureFlagFetcher, + }) + .withEventCallback({ + callback: cacheEventToSentryMetric("featureFlag"), }); /** diff --git a/vendor/backend/src/feature-flag/events.js b/vendor/backend/src/feature-flag/events.js index 45992f5..6853203 100644 --- a/vendor/backend/src/feature-flag/events.js +++ b/vendor/backend/src/feature-flag/events.js @@ -7,9 +7,10 @@ import { featureFlagCache } from "./cache.js"; * * @param {import("@compas/stdlib").InsightEvent} event * @param {QueryResultBackendTenant} tenant + * @param {QueryResultAuthUser} user * @returns {Promise} */ -export async function featureFlagCurrent(event, tenant) { +export async function featureFlagCurrent(event, tenant, user) { eventStart(event, "featureFlag.current"); let flags = featureFlagCache.getAll(); @@ -40,11 +41,11 @@ export async function featureFlagCurrent(event, tenant) { continue; } - const tenantSpecificValue = flag?.tenantValues?.[tenant?.name]; + const tenantSpecificValue = flag?.tenantValues?.[tenant?.name] ?? false; + const userSpecificValue = flag?.userValues?.[user?.id] ?? false; - result[flag.name] = !isNil(tenantSpecificValue) - ? tenantSpecificValue - : flag.globalValue; + result[flag.name] = + flag.globalValue || tenantSpecificValue || userSpecificValue; } for (const flag of featureFlags.availableFlags) { @@ -108,11 +109,13 @@ export async function featureFlagGetDynamic(event, tenant, user, identifier) { eventStart(event, "featureFlag.getDynamic"); const flag = await featureFlagCache.get(identifier); - const tenantSpecificValue = flag?.tenantValues?.[tenant?.tenant?.name]; + const tenantSpecificValue = + flag?.tenantValues?.[tenant?.tenant?.name] ?? false; + const userSpecificValue = flag?.userValues?.[user?.id] ?? false; eventStop(event); - return !isNil(tenantSpecificValue) ? tenantSpecificValue : flag.globalValue; + return flag?.globalValue || tenantSpecificValue || userSpecificValue; } /** @@ -123,6 +126,7 @@ export async function featureFlagGetDynamic(event, tenant, user, identifier) { * @param {FeatureFlagIdentifier} identifier * @param {boolean} value * @param {BackendFeatureFlag["tenantValues"]} tenantValues + * @param {BackendFeatureFlag["userValues"]} userValues * @returns {Promise} */ export async function featureFlagSetDynamic( @@ -130,6 +134,7 @@ export async function featureFlagSetDynamic( identifier, value, tenantValues = undefined, + userValues = undefined, ) { eventStart(event, "featureFlag.setDynamic"); @@ -152,14 +157,15 @@ export async function featureFlagSetDynamic( }, update: { globalValue: value, - tenantValues, + tenantValues: tenantValues ?? null, + userValues: userValues ?? null, }, }); if (featureFlagCache.isEnabled()) { - // Clear the cache if enabled. This method function is often only used in test code, which most likely disables the cache anyways. - featureFlagCache.disable(); - featureFlagCache.enable(); + // Clear the cache if enabled. This method function is often only used in test code, which most + // likely disables the cache anyways. + featureFlagCache.clearAll(); } eventStop(event); diff --git a/vendor/backend/src/feature-flag/init.js b/vendor/backend/src/feature-flag/init.js index 0692649..4fe64af 100644 --- a/vendor/backend/src/feature-flag/init.js +++ b/vendor/backend/src/feature-flag/init.js @@ -1,5 +1,7 @@ import { eventStart, eventStop, newEventFromEvent } from "@compas/stdlib"; +import { authLoadSessionOptionally } from "../auth/events.js"; import { multitenantRequireTenant } from "../multitenant/events.js"; +import { sql } from "../services.js"; import { importProjectResource } from "../util.js"; import { featureFlagCurrent, featureFlagSyncAvailableFlags } from "./events.js"; @@ -7,13 +9,13 @@ import { featureFlagCurrent, featureFlagSyncAvailableFlags } from "./events.js"; * Initialize feature flag system. * * @param {import("@compas/stdlib").InsightEvent} event - * @param {import("@compas/store").Postgres} sql + * @param {import("@compas/store").Postgres} initSql * @returns {Promise} */ -export async function featureFlagInit(event, sql) { +export async function featureFlagInit(event, initSql) { eventStart(event, "featureFlag.init"); - await featureFlagSyncAvailableFlags(newEventFromEvent(event), sql); + await featureFlagSyncAvailableFlags(newEventFromEvent(event), initSql); /** * @type {typeof @@ -28,8 +30,17 @@ export async function featureFlagInit(event, sql) { newEventFromEvent(ctx.event), ctx, ); + const session = await authLoadSessionOptionally( + newEventFromEvent(ctx.event), + sql, + ctx, + ); - ctx.body = await featureFlagCurrent(newEventFromEvent(ctx.event), tenant); + ctx.body = await featureFlagCurrent( + newEventFromEvent(ctx.event), + tenant, + session ? { id: session.userId } : undefined, + ); if (next) { return next(); diff --git a/vendor/backend/src/feature-flag/structure.js b/vendor/backend/src/feature-flag/structure.js index 4712526..23b6053 100644 --- a/vendor/backend/src/feature-flag/structure.js +++ b/vendor/backend/src/feature-flag/structure.js @@ -5,7 +5,7 @@ import { lpcInternalFeatureFlags } from "../constants.js"; * * @param {import("@compas/code-gen").App} app * @param {{ - * flagDefinition: BackendFeatureFlagDefinitionInput, + * flagDefinition: BackendFeatureFlagDefinition, * }} options * @returns {Promise} */ diff --git a/vendor/backend/src/init.js b/vendor/backend/src/init.js index a80cfba..e41a60d 100644 --- a/vendor/backend/src/init.js +++ b/vendor/backend/src/init.js @@ -57,7 +57,7 @@ import { sql } from "./services.js"; * }} sessionTransportSettings Compas session transport and store settings. The signing * key is injected in development, and in production it defaults to * 'environment.APP_KEYS'. - * @property {string[]} [permissionIdentifiers] + * @property {Array} [permissionIdentifiers] * @property {AuthCombineUserCallbacks} [combineUserCallbacks] Combine users on login, * for example to upgrade anonymous user to password based user and keep things like a * shopping cart. diff --git a/vendor/backend/src/management/structure.js b/vendor/backend/src/management/structure.js index b8abf25..cc2b946 100644 --- a/vendor/backend/src/management/structure.js +++ b/vendor/backend/src/management/structure.js @@ -32,7 +32,7 @@ export async function extendWithManagement(app) { .fields({ readable: {}, writable: { - $pick: ["globalValue", "tenantValues", "description"], + $pick: ["globalValue", "tenantValues", "description", "userValues"], }, }), ); diff --git a/vendor/backend/src/multitenant/cache.js b/vendor/backend/src/multitenant/cache.js index 3b4bde4..0688b7d 100644 --- a/vendor/backend/src/multitenant/cache.js +++ b/vendor/backend/src/multitenant/cache.js @@ -1,6 +1,7 @@ import { isNil, uuid } from "@compas/stdlib"; import { PullThroughCache } from "@lightbase/pull-through-cache"; import { queryTenant, sql, tenantBuilder } from "../services.js"; +import { cacheEventToSentryMetric } from "../util.js"; /** * Frequently sampled tenant cache. @@ -19,6 +20,9 @@ export const tenantCache = new PullThroughCache() }) .withFetcher({ fetcher: tenantFetcher, + }) + .withEventCallback({ + callback: cacheEventToSentryMetric("tenant"), }); /** diff --git a/vendor/backend/src/multitenant/config.js b/vendor/backend/src/multitenant/config.js index 129e62c..6c5086a 100644 --- a/vendor/backend/src/multitenant/config.js +++ b/vendor/backend/src/multitenant/config.js @@ -134,7 +134,7 @@ export async function multitenantLoadConfig() { /** * Get a list of all enabled tenants in the current environment. * - * @returns {Promise} + * @returns {Promise>} */ export async function multitenantEnabledTenantNames() { const { tenantsByName } = await multitenantLoadConfig(); diff --git a/vendor/backend/src/multitenant/events.js b/vendor/backend/src/multitenant/events.js index 3ada8e4..6dffaa2 100644 --- a/vendor/backend/src/multitenant/events.js +++ b/vendor/backend/src/multitenant/events.js @@ -143,7 +143,8 @@ export async function multitenantLoadByContext(ctx) { configTenant?.urlConfig ?? {}, )) { if (spec.apiUrl === hostWithoutProtocol) { - // We can't really check if the publicUrl is same as origin, since SSR does not send an origin header + // We can't really check if the publicUrl is same as origin, since SSR does not send an + // origin header result.publicUrl = `${ctx.protocol}://${publicUrl}`; break; } @@ -182,7 +183,8 @@ export async function multitenantLoadByContext(ctx) { configTenant?.urlConfig ?? {}, )) { if (spec.apiUrl === hostWithoutProtocol) { - // We can't really check if the publicUrl is same as origin, since SSR / Apps does not send an origin header + // We can't really check if the publicUrl is same as origin, since SSR / Apps does not + // send an origin header result.publicUrl = `${ctx.protocol}://${publicUrl}`; break; } diff --git a/vendor/backend/src/services.js b/vendor/backend/src/services.js index 649a49f..b6b7093 100644 --- a/vendor/backend/src/services.js +++ b/vendor/backend/src/services.js @@ -120,6 +120,12 @@ export let userBuilder = { */ export let tenantBuilder = {}; +/** + * @type {(user: QueryResultAuthUser) => void}} + */ + +export let onAuthRequireUserCallback = (_user) => {}; + /** @type {import("@compas/store").SessionTransportSettings} */ // @ts-expect-error // @@ -162,6 +168,7 @@ export let sessionDurationCallback = () => ({ * @param {{ * userBuilder?: AuthUserQueryBuilder, * tenantBuilder?: BackendTenantQueryBuilder, + * onAuthRequireUserCallback?: (user: QueryResultAuthUser) => void, * shouldPasswordBasedForcePasswordResetAfterSixMonths?: boolean, * shouldPasswordBasedRollingLoginAttemptBlock?: boolean, * shouldPasswordBasedUpdatePasswordRemoveCurrentSession?: boolean, @@ -277,6 +284,9 @@ export async function backendInitServices(other) { tenantBuilder = { ...other.tenantBuilder, ...tenantBuilder }; } + onAuthRequireUserCallback = + other.onAuthRequireUserCallback ?? onAuthRequireUserCallback; + passwordBasedForcePasswordResetAfterSixMonths = other.shouldPasswordBasedForcePasswordResetAfterSixMonths ?? false; passwordBasedRollingLoginAttemptBlock = diff --git a/vendor/backend/src/slack/events.js b/vendor/backend/src/slack/events.js index 3d17a95..bc2c8ce 100644 --- a/vendor/backend/src/slack/events.js +++ b/vendor/backend/src/slack/events.js @@ -115,7 +115,8 @@ export async function slackInvalidateConversations(event) { }); if (!ok && error === "message_not_found") { - // Message could be removed by another instance of LPC which is executing this function, so we can safely continue; + // Message could be removed by another instance of LPC which is executing this function, so + // we can safely continue; continue; } diff --git a/vendor/backend/src/structure.js b/vendor/backend/src/structure.js index 34078d6..b177b37 100644 --- a/vendor/backend/src/structure.js +++ b/vendor/backend/src/structure.js @@ -96,6 +96,17 @@ export async function extendWithBackendBase(app) { ), T.object("featureFlag") + .docs( + ` + Layered feature-flag settings: + + - Global setting + - Tenant setting + - User setting + + A flag is returned enabled if user or tenant or global value is enabled. + `, + ) .keys({ name: T.string().searchable(), description: T.string().min(0).default(`""`), @@ -107,6 +118,11 @@ export async function extendWithBackendBase(app) { .docs( "Specific settings for a tenant. We map the value based on the tenant name. If there is no specific setting for the tenant the globalValue is used.", ), + userValues: T.generic() + .keys(T.uuid()) + .values(T.bool()) + .optional() + .docs(`Specific settings per user.`), }) .enableQueries({ withDates: true, diff --git a/vendor/backend/src/util.js b/vendor/backend/src/util.js index 964f996..b4e7937 100644 --- a/vendor/backend/src/util.js +++ b/vendor/backend/src/util.js @@ -1,5 +1,24 @@ import { existsSync } from "node:fs"; -import { AppError, isNil, pathJoin } from "@compas/stdlib"; +import { _compasSentryExport, AppError, isNil, pathJoin } from "@compas/stdlib"; + +/** + * Count the different PullThroughCache events as their own metric + * + * @param {string} cacheName + * @returns {(function(string): void)} + */ +export function cacheEventToSentryMetric(cacheName) { + return (event) => { + if (_compasSentryExport?.metrics?.increment) { + _compasSentryExport.metrics.increment(`cache.${event}`, 1, { + tags: { + cacheName, + }, + unit: "none", + }); + } + }; +} /** * Takes an AppError and normalizes it to a 401, to simplify frontend error handling on