diff --git a/docs-mintlify/admin/users-and-permissions/index.mdx b/docs-mintlify/admin/users-and-permissions/index.mdx deleted file mode 100644 index 1fa8c9904bd48..0000000000000 --- a/docs-mintlify/admin/users-and-permissions/index.mdx +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Manage user attributes, roles, permissions, and groups to control access to Cube features and data. ---- - - Users and permissions - -Manage users and control access to features and data in Cube. - -## Users and permissions - -Configure access control to: - -- Define [user attributes][ref-attributes] for personalized data access -- Set up [roles and permissions][ref-roles] to control feature access -- Create [custom roles][ref-custom-roles] with specific permissions for your team - -[ref-attributes]: /admin/users-and-permissions/user-attributes -[ref-roles]: /admin/users-and-permissions/roles-and-permissions -[ref-custom-roles]: /admin/users-and-permissions/custom-roles \ No newline at end of file diff --git a/docs-mintlify/admin/users-and-permissions/manage-users.mdx b/docs-mintlify/admin/users-and-permissions/manage-users.mdx new file mode 100644 index 0000000000000..0bc97d85964ac --- /dev/null +++ b/docs-mintlify/admin/users-and-permissions/manage-users.mdx @@ -0,0 +1,119 @@ +--- +title: Manage users +description: Add users to your Cube Cloud account, assign roles, and control their access. +--- + +Use the **Admin → Users** page to add people to your Cube Cloud account, +change their roles, and control what they can access. + + + +Only users with the Admin role can manage other users. + + + +## The user list + +The user list displays all users in your account along with their roles +and status. Use the search bar to filter users by name or email. + +## Inviting users + +To invite a new user: + +1. Navigate to **Admin → Users**. +2. Click **Add User**. +3. Enter the user's email address. +4. Select a [role][ref-roles] for the user: Admin, Developer, Explorer, or + Viewer. +5. Optionally, assign one or more [custom roles][ref-custom-roles]. +6. Click **Create** to send the invitation. + +After the user is created, an invitation link is generated. You can copy the +link and share it with the user. The user must visit the link to set their +password and activate their account. + +### Resending invitations + +If a user hasn't activated their account, you can resend or copy the +invitation link from the user list. + +## Managing individual users + +Click on a user in the user list to access their settings page. From here, +you can: + +- Update the user's name +- Change their [role][ref-roles] (Admin, Developer, Explorer, or Viewer) +- Assign or remove [custom roles][ref-custom-roles] +- Add the user to [user groups][ref-groups] +- Set [user attribute][ref-attributes] values for data access control + +### Changing a user's role + +To change a user's role: + +1. Navigate to **Admin → Users** and click on the user. +2. Select a new role from the role dropdown. +3. Save the changes. + +Alternatively, you can change a user's role directly from the user list +using the role dropdown. + + + +Admin roles are billed at the developer rate. + + + +## Deactivating and reactivating users + +Deactivating a user revokes their access to Cube Cloud without permanently +removing their account. The user's active sessions are terminated immediately. + +To deactivate a user: + +1. Navigate to **Admin → Users**. +2. Click the actions menu for the user. +3. Select **Deactivate**. + + + +You cannot deactivate yourself or the last active Admin user. + + + +To reactivate a deactivated user, follow the same steps and select +**Activate**. The user can then log in again with their existing credentials. + +## Deleting users + +Deleting a user permanently removes their account from Cube Cloud. + +To delete a user: + +1. Navigate to **Admin → Users**. +2. Click the actions menu for the user. +3. Select **Delete**. + + + +You cannot delete your own account. Deleting a user is irreversible. + + + +## Provisioning users via SCIM + +If your organization uses an identity provider such as [Okta][ref-okta] or +[Microsoft Entra ID][ref-entra-id], you can automate user provisioning and +deprovisioning through SCIM. See the [SSO & Identity Providers][ref-sso] +documentation for setup instructions. + + +[ref-roles]: /admin/users-and-permissions/roles-and-permissions +[ref-custom-roles]: /admin/users-and-permissions/custom-roles +[ref-attributes]: /admin/users-and-permissions/user-attributes +[ref-groups]: /admin/users-and-permissions/user-groups +[ref-sso]: /admin/sso +[ref-okta]: /admin/sso/okta/scim +[ref-entra-id]: /admin/sso/microsoft-entra-id/scim diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json index 67b4a81418dc2..8de0e22ae4bb3 100644 --- a/docs-mintlify/docs.json +++ b/docs-mintlify/docs.json @@ -205,7 +205,7 @@ { "group": "Users & Permissions", "pages": [ - "admin/users-and-permissions", + "admin/users-and-permissions/manage-users", "admin/users-and-permissions/roles-and-permissions", "admin/users-and-permissions/user-attributes", "admin/users-and-permissions/user-groups", diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts index 655ef53e20056..33c2f5f3b6f4a 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts @@ -1480,35 +1480,79 @@ export class CubeSymbols implements TranspilerSymbolResolver, CompilerInterface } public static contextSymbolsProxyFrom(symbols: object, allocateParam: (param: unknown) => unknown): object { - return new Proxy(symbols, { - get: (target, name) => { - const propValue = target[name]; - const methods = (paramValue) => ({ - filter: (column) => { - if (paramValue) { - const value = Array.isArray(paramValue) ? - paramValue.map(allocateParam) : - allocateParam(paramValue); - if (typeof column === 'function') { - return column(value); - } else { - return `${column} = ${value}`; - } + const methods = (paramValue) => ({ + filter: (column) => { + if (paramValue) { + if (Array.isArray(paramValue)) { + const values = paramValue.map(allocateParam); + if (typeof column === 'function') { + return column(values); } else { - return '1 = 1'; + return `${column} IN (${values.join(', ')})`; } - }, - requiredFilter: (column) => { - if (!paramValue) { - throw new UserError(`Filter for ${column} is required`); + } else { + const value = allocateParam(paramValue); + if (typeof column === 'function') { + return column(value); + } else { + return `${column} = ${value}`; } - return methods(paramValue).filter(column); - }, - unsafeValue: () => paramValue - }); - return methods(target)[name] || - typeof propValue === 'object' && propValue !== null && CubeSymbols.contextSymbolsProxyFrom(propValue, allocateParam) || - methods(propValue); + } + } else { + return '1 = 1'; + } + }, + requiredFilter: (column) => { + if (!paramValue) { + throw new UserError(`Filter for ${column} is required`); + } + return methods(paramValue).filter(column); + }, + unsafeValue: () => paramValue, + toString: () => { + if (paramValue !== undefined && paramValue !== null) { + return Array.isArray(paramValue) + ? paramValue.map(allocateParam).join(',') + : String(allocateParam(paramValue)); + } + return ''; + }, + [Symbol.toPrimitive]: () => { + if (paramValue !== undefined && paramValue !== null) { + return Array.isArray(paramValue) + ? paramValue.map(allocateParam).join(',') + : String(allocateParam(paramValue)); + } + return ''; + } + }); + + // Chainable proxy for undefined/null values: supports both method calls + // (filter, unsafeValue, etc.) and further property chaining for deeply + // nested paths like SECURITY_CONTEXT.cubeCloud.tenantId.filter(...) + // when the security context is empty during compilation/dep resolution. + const undefinedChainableHandler: ProxyHandler = { + get: (target, name) => { + if (name in target || typeof name === 'symbol') return target[name]; + return new Proxy(methods(undefined), undefinedChainableHandler); + } + }; + + return new Proxy(symbols, { + get: (target, name) => { + const propValue = target[name]; + const methodOnTarget = methods(target)[name]; + if (methodOnTarget) return methodOnTarget; + + if (typeof propValue === 'object' && propValue !== null) { + return CubeSymbols.contextSymbolsProxyFrom(propValue, allocateParam); + } + + if (propValue !== undefined && propValue !== null) { + return methods(propValue); + } + + return new Proxy(methods(undefined), undefinedChainableHandler); } }); } diff --git a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts index 06beef569bc6b..d007111181876 100644 --- a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts @@ -214,17 +214,22 @@ export class CubePropContextTranspiler implements TranspilerInterface { protected static collectKnownIdentifiersAndTransform(resolveSymbol: SymbolResolver, path: NodePath): string[] { const identifiers: string[] = []; + const isAccessPolicy = this.isAccessPolicyPath(path); if (path.node.type === 'Identifier') { - CubePropContextTranspiler.matchAndTransformIdentifier(path, resolveSymbol, identifiers); + CubePropContextTranspiler.transformCubeCloudShorthandIdentifier(path as NodePath, identifiers, isAccessPolicy, resolveSymbol); + if (path.node.type === 'Identifier') { + CubePropContextTranspiler.matchAndTransformIdentifier(path, resolveSymbol, identifiers); + } } path.traverse({ Identifier: (p) => { + CubePropContextTranspiler.transformCubeCloudShorthandIdentifier(p, identifiers, isAccessPolicy, resolveSymbol); CubePropContextTranspiler.matchAndTransformIdentifier(p, resolveSymbol, identifiers); }, MemberExpression: (p) => { - CubePropContextTranspiler.transformUserAttributesMemberExpression(p); + CubePropContextTranspiler.transformCubeCloudShorthandMemberExpression(p, isAccessPolicy, resolveSymbol); } }); @@ -238,39 +243,83 @@ export class CubePropContextTranspiler implements TranspilerInterface { ) && resolveSymbol(path.node.name) ) { - // Special handling for userAttributes - replace in parameter list with securityContext - const fullPath = this.fullPath(path); - if ((path.node.name === 'userAttributes' || path.node.name === 'user_attributes') && (fullPath.startsWith('accessPolicy') || fullPath.startsWith('access_policy'))) { - identifiers.push('securityContext'); - } else { - identifiers.push(path.node.name); + identifiers.push(path.node.name); + } + } + + private static readonly CUBE_CLOUD_SHORTHAND_IDENTIFIERS = ['userAttributes', 'user_attributes', 'groups']; + + private static isAccessPolicyPath(path: NodePath): boolean { + // @ts-ignore + const target = (!path?.node?.key && path?.parentPath && t.isObjectProperty(path.parentPath.node)) + ? path.parentPath + : path; + const fp = this.fullPath(target); + return fp.startsWith('accessPolicy') || fp.startsWith('access_policy'); + } + + private static securityContextIdentifier(isAccessPolicy: boolean): t.Identifier { + return t.identifier(isAccessPolicy ? 'securityContext' : 'SECURITY_CONTEXT'); + } + + private static isShadowedByFunctionParam(name: string, path: NodePath): boolean { + let current: NodePath | null = path.parentPath; + while (current) { + const { node } = current; + if ( + (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) && + node.params.some(p => t.isIdentifier(p) && p.name === name) + ) { + return true; } + current = current.parentPath; } + return false; } - protected static transformUserAttributesMemberExpression(path: NodePath) { - // Check if this is userAttributes.someProperty (object should be identifier named 'userAttributes') - const fullPath = this.fullPath(path); + protected static transformCubeCloudShorthandIdentifier(path: NodePath, identifiers: string[], isAccessPolicy: boolean, resolveSymbol: SymbolResolver) { + if (!this.CUBE_CLOUD_SHORTHAND_IDENTIFIERS.includes(path.node.name)) { + return; + } + if (resolveSymbol(path.node.name)) { + return; + } if ( - (t.isIdentifier(path.node.object, { name: 'userAttributes' }) || t.isIdentifier(path.node.object, { name: 'user_attributes' })) && - (fullPath.startsWith('accessPolicy') || fullPath.startsWith('access_policy')) + path.parent && + (path.parent.type === 'MemberExpression' || path.parent.type === 'OptionalMemberExpression') && + path.key === 'property' ) { - // Replace userAttributes with securityContext.cubeCloud.userAttributes - const securityContext = t.identifier('securityContext'); - const cubeCloud = t.memberExpression(securityContext, t.identifier('cubeCloud')); - const userAttributes = t.memberExpression(cubeCloud, t.identifier('userAttributes')); - const newMemberExpression = t.memberExpression(userAttributes, path.node.property, path.node.computed); + return; + } + if (this.isShadowedByFunctionParam(path.node.name, path)) { + return; + } + const contextId = this.securityContextIdentifier(isAccessPolicy); + const cubeCloud = t.memberExpression(contextId, t.identifier('cubeCloud')); + const prop = path.node.name === 'user_attributes' ? 'userAttributes' : path.node.name; + const newExpr = t.memberExpression(cubeCloud, t.identifier(prop)); + path.replaceWith(newExpr); + identifiers.push(contextId.name); + } + protected static transformCubeCloudShorthandMemberExpression(path: NodePath, isAccessPolicy: boolean, resolveSymbol: SymbolResolver) { + if ( + t.isIdentifier(path.node.object) && + this.CUBE_CLOUD_SHORTHAND_IDENTIFIERS.includes(path.node.object.name) && + !resolveSymbol(path.node.object.name) && + !this.isShadowedByFunctionParam(path.node.object.name, path) + ) { + const contextId = this.securityContextIdentifier(isAccessPolicy); + const cubeCloud = t.memberExpression(contextId, t.identifier('cubeCloud')); + const prop = path.node.object.name === 'user_attributes' ? 'userAttributes' : path.node.object.name; + const shorthand = t.memberExpression(cubeCloud, t.identifier(prop)); + const newMemberExpression = t.memberExpression(shorthand, path.node.property, path.node.computed); path.replaceWith(newMemberExpression); } else if ( - t.isMemberExpression(path.node.object) && + (t.isMemberExpression(path.node.object) || t.isOptionalMemberExpression(path.node.object)) && t.isIdentifier(path.node.object.property, { name: 'user_attributes' }) && - !path.node.object.computed && - (fullPath.startsWith('accessPolicy') || fullPath.startsWith('access_policy')) + !path.node.object.computed ) { - // Also handle case where user_attributes appears within a MemberExpression chain like - // securityContext.cubeCloud.user_attributes - // We need to convert user_attributes to userAttributes in such chains const newObject = t.memberExpression( path.node.object.object, t.identifier('userAttributes'), diff --git a/packages/cubejs-schema-compiler/test/unit/base-query.test.ts b/packages/cubejs-schema-compiler/test/unit/base-query.test.ts index 125de7ab3b016..b00945d95dc42 100644 --- a/packages/cubejs-schema-compiler/test/unit/base-query.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/base-query.test.ts @@ -2683,4 +2683,375 @@ describe('Class unit tests', () => { const re = new RegExp('(b__aid).*(b__bval_sum).*(b__count).*'); expect(re.test(sql[0])).toBeTruthy(); }); + + describe('SECURITY_CONTEXT unsafeValue with nested properties', () => { + // language=JavaScript + const securityContextCompilers = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM \${SECURITY_CONTEXT.cubeCloud.groups.unsafeValue() === 'admin' ? 'admin_orders' : 'public_orders'}\`, + + measures: { + count: { + type: 'count', + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primaryKey: true, + }, + }, + }); + `); + + it('should resolve nested unsafeValue to leaf value, not parent object', async () => { + await securityContextCompilers.compiler.compile(); + + const query = new PostgresQuery(securityContextCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { groups: 'admin' } } + } + }); + const [sql] = query.buildSqlAndParams(); + expect(sql).toContain('admin_orders'); + expect(sql).not.toContain('public_orders'); + }); + + it('should resolve nested unsafeValue to non-matching leaf value', async () => { + await securityContextCompilers.compiler.compile(); + + const query = new PostgresQuery(securityContextCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { groups: 'viewer' } } + } + }); + const [sql] = query.buildSqlAndParams(); + expect(sql).toContain('public_orders'); + expect(sql).not.toContain('admin_orders'); + }); + }); + + describe('SECURITY_CONTEXT nested array filter with IN clause', () => { + // language=JavaScript + const securityContextArrayCompilers = prepareJsCompiler(` + cube(\`orders\`, { + sql: \` + SELECT * FROM orders + WHERE \${SECURITY_CONTEXT.cubeCloud.groups.filter(groups => \`source IN (\${groups.join(',')})\`)} + \`, + + measures: { + count: { + type: 'count', + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primaryKey: true, + }, + }, + }); + `); + + it('should generate IN clause with params for nested array filter', async () => { + await securityContextArrayCompilers.compiler.compile(); + + const query = new PostgresQuery(securityContextArrayCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { groups: ['admin', 'operator'] } } + } + }); + const [sql, params] = query.buildSqlAndParams(); + expect(sql).toContain('source IN ('); + expect(sql).not.toContain('= '); + expect(params).toContain('admin'); + expect(params).toContain('operator'); + }); + + it('should generate 1=1 when nested array filter value is missing', async () => { + await securityContextArrayCompilers.compiler.compile(); + + const query = new PostgresQuery(securityContextArrayCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: {} } + } + }); + const [sql] = query.buildSqlAndParams(); + expect(sql).toContain('1 = 1'); + }); + }); + + describe('SECURITY_CONTEXT toString renders as param in interpolation', () => { + // language=JavaScript + const securityContextToStringCompilers = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders WHERE tenant_id = \${SECURITY_CONTEXT.cubeCloud.tenantId}\`, + + measures: { + count: { + type: 'count', + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primaryKey: true, + }, + }, + }); + `); + + it('should render nested primitive as param placeholder in SQL', async () => { + await securityContextToStringCompilers.compiler.compile(); + + const query = new PostgresQuery(securityContextToStringCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { tenantId: 'tenant_123' } } + } + }); + const [sql, params] = query.buildSqlAndParams(); + expect(sql).toMatch(/tenant_id = \$\d+/); + expect(params).toContain('tenant_123'); + }); + }); + + describe('SECURITY_CONTEXT with Tesseract (native SQL planner)', () => { + // language=JavaScript + const unsafeValueCompilers = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM \${SECURITY_CONTEXT.cubeCloud.groups.unsafeValue() === 'admin' ? 'admin_orders' : 'public_orders'}\`, + + measures: { + count: { + type: 'count', + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primaryKey: true, + }, + }, + }); + `); + + it('tesseract: unsafeValue resolves nested leaf value', async () => { + await unsafeValueCompilers.compiler.compile(); + + const query = new PostgresQuery(unsafeValueCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { groups: 'admin' } } + }, + useNativeSqlPlanner: true, + }); + const [sql] = query.buildSqlAndParams(); + expect(sql).toContain('admin_orders'); + expect(sql).not.toContain('public_orders'); + }); + + it('tesseract: unsafeValue resolves non-matching leaf value', async () => { + await unsafeValueCompilers.compiler.compile(); + + const query = new PostgresQuery(unsafeValueCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { groups: 'viewer' } } + }, + useNativeSqlPlanner: true, + }); + const [sql] = query.buildSqlAndParams(); + expect(sql).toContain('public_orders'); + expect(sql).not.toContain('admin_orders'); + }); + + // language=JavaScript + const arrayFilterCompilers = prepareJsCompiler(` + cube(\`orders\`, { + sql: \` + SELECT * FROM orders + WHERE \${SECURITY_CONTEXT.cubeCloud.groups.filter(groups => \`source IN (\${groups.join(',')})\`)} + \`, + + measures: { + count: { + type: 'count', + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primaryKey: true, + }, + }, + }); + `); + + it('tesseract: array filter generates IN clause with params', async () => { + await arrayFilterCompilers.compiler.compile(); + + const query = new PostgresQuery(arrayFilterCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { groups: ['admin', 'operator'] } } + }, + useNativeSqlPlanner: true, + }); + const [sql, params] = query.buildSqlAndParams(); + expect(sql).toContain('source IN ('); + expect(params).toContain('admin'); + expect(params).toContain('operator'); + }); + + it('tesseract: missing array filter value generates 1=1', async () => { + await arrayFilterCompilers.compiler.compile(); + + const query = new PostgresQuery(arrayFilterCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: {} } + }, + useNativeSqlPlanner: true, + }); + const [sql] = query.buildSqlAndParams(); + expect(sql).toContain('1 = 1'); + }); + + // language=JavaScript + const toStringCompilers = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders WHERE tenant_id = \${SECURITY_CONTEXT.cubeCloud.tenantId}\`, + + measures: { + count: { + type: 'count', + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primaryKey: true, + }, + }, + }); + `); + + it('tesseract: toString renders param placeholder in SQL', async () => { + await toStringCompilers.compiler.compile(); + + const query = new PostgresQuery(toStringCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { tenantId: 'tenant_123' } } + }, + useNativeSqlPlanner: true, + }); + const [sql, params] = query.buildSqlAndParams(); + expect(sql).toMatch(/tenant_id = \$\d+/); + expect(params).toContain('tenant_123'); + }); + + // language=JavaScript + const filterStringColumnCompilers = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders WHERE \${SECURITY_CONTEXT.cubeCloud.tenantId.filter('tenant_id')}\`, + + measures: { + count: { + type: 'count', + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primaryKey: true, + }, + }, + }); + `); + + it('tesseract: filter with string column and scalar value generates equality', async () => { + await filterStringColumnCompilers.compiler.compile(); + + const query = new PostgresQuery(filterStringColumnCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { tenantId: 'abc' } } + }, + useNativeSqlPlanner: true, + }); + const [sql, params] = query.buildSqlAndParams(); + expect(sql).toMatch(/tenant_id = \$\d+/); + expect(params).toContain('abc'); + }); + + // language=JavaScript + const filterStringColumnArrayCompilers = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders WHERE \${SECURITY_CONTEXT.cubeCloud.groups.filter('source')}\`, + + measures: { + count: { + type: 'count', + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primaryKey: true, + }, + }, + }); + `); + + it('tesseract: filter with string column and array value generates IN clause', async () => { + await filterStringColumnArrayCompilers.compiler.compile(); + + const query = new PostgresQuery(filterStringColumnArrayCompilers, { + measures: ['orders.count'], + timeDimensions: [], + contextSymbols: { + securityContext: { cubeCloud: { groups: ['admin', 'operator'] } } + }, + useNativeSqlPlanner: true, + }); + const [sql, params] = query.buildSqlAndParams(); + expect(sql).toContain('source IN ('); + expect(params).toContain('admin'); + expect(params).toContain('operator'); + }); + }); }); diff --git a/packages/cubejs-schema-compiler/test/unit/context-symbols-proxy.test.ts b/packages/cubejs-schema-compiler/test/unit/context-symbols-proxy.test.ts new file mode 100644 index 0000000000000..906b7a8666884 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/context-symbols-proxy.test.ts @@ -0,0 +1,123 @@ +import { CubeSymbols } from '../../src/compiler/CubeSymbols'; + +describe('CubeSymbols.contextSymbolsProxyFrom', () => { + const allocateParam = (param: unknown) => `__param(${JSON.stringify(param)})`; + + it('unsafeValue returns leaf primitive value, not parent object', () => { + const symbols = { cubeCloud: { groups: 'admin' } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + expect(proxy.cubeCloud.groups.unsafeValue()).toBe('admin'); + }); + + it('unsafeValue returns leaf array value, not parent object', () => { + const symbols = { cubeCloud: { groups: ['admin', 'user'] } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + expect(proxy.cubeCloud.groups.unsafeValue()).toEqual(['admin', 'user']); + }); + + it('unsafeValue returns intermediate object at each level', () => { + const symbols = { cubeCloud: { groups: ['admin'] } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + expect(proxy.cubeCloud.unsafeValue()).toEqual({ groups: ['admin'] }); + expect(proxy.unsafeValue()).toEqual(symbols); + }); + + it('unsafeValue returns undefined for missing properties', () => { + const symbols = { cubeCloud: { groups: ['admin'] } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + expect(proxy.cubeCloud.nonExistent.unsafeValue()).toBeUndefined(); + }); + + it('unsafeValue returns numeric zero correctly', () => { + const symbols = { tenant: { id: 0 } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + expect(proxy.tenant.id.unsafeValue()).toBe(0); + }); + + it('filter works on nested primitive values', () => { + const symbols = { cubeCloud: { tenantId: 'abc' } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + expect(proxy.cubeCloud.tenantId.filter('col')).toContain('col'); + }); + + it('filter returns 1=1 for missing values', () => { + const symbols = { cubeCloud: {} }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + expect(proxy.cubeCloud.tenantId.filter('col')).toBe('1 = 1'); + }); + + it('filter with nested array passes allocated params to function for IN clause', () => { + const symbols = { cubeCloud: { groups: ['admin', 'user'] } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + const result = proxy.cubeCloud.groups.filter( + (groups) => `col IN (${groups.join(', ')})` + ); + expect(result).toBe('col IN (__param("admin"), __param("user"))'); + }); + + it('filter with nested array and string column produces IN clause', () => { + const symbols = { cubeCloud: { groups: ['admin', 'user'] } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + const result = proxy.cubeCloud.groups.filter('col'); + expect(result).toBe('col IN (__param("admin"), __param("user"))'); + }); + + it('filter with nested primitive and string column produces equality', () => { + const symbols = { cubeCloud: { tenantId: 'abc' } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + const result = proxy.cubeCloud.tenantId.filter('col'); + expect(result).toBe('col = __param("abc")'); + }); + + it('toString on nested primitive allocates param for interpolation', () => { + const symbols = { cubeCloud: { tenantId: 'abc' } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + const result = `${proxy.cubeCloud.tenantId}`; + expect(result).toBe('__param("abc")'); + }); + + it('toString on nested array allocates each element as param', () => { + const symbols = { cubeCloud: { groups: ['admin', 'user'] } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + const result = `${proxy.cubeCloud.groups}`; + expect(result).toBe('__param("admin"),__param("user")'); + }); + + it('toString on proxy wrapping array allocates params via toPrimitive', () => { + const symbols = { cubeCloud: { groups: ['admin', 'user'] } }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + const result = String(proxy.cubeCloud.groups); + expect(result).toBe('__param("admin"),__param("user")'); + }); + + it('toString on missing property returns empty string', () => { + const symbols = { cubeCloud: {} }; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + const result = `${proxy.cubeCloud.tenantId}`; + expect(result).toBe(''); + }); + + it('deeply nested access on empty context chains without error', () => { + const symbols = {}; + const proxy = CubeSymbols.contextSymbolsProxyFrom(symbols, allocateParam) as any; + + expect(proxy.cubeCloud.tenantId.filter('col')).toBe('1 = 1'); + expect(proxy.cubeCloud.tenantId.unsafeValue()).toBeUndefined(); + expect(`${proxy.cubeCloud.tenantId}`).toBe(''); + expect(proxy.a.b.c.d.e.filter('col')).toBe('1 = 1'); + }); +}); diff --git a/packages/cubejs-schema-compiler/test/unit/transpilers.test.ts b/packages/cubejs-schema-compiler/test/unit/transpilers.test.ts index aaf38133538f8..6493b6305757b 100644 --- a/packages/cubejs-schema-compiler/test/unit/transpilers.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/transpilers.test.ts @@ -183,6 +183,201 @@ describe('Transpilers', () => { expect(transpiledValues.toString()).toMatch('securityContext.cubeCloud.userAttributes.userId'); }); + it('CubePropContextTranspiler with shorthand groups in values should transpile to securityContext.cubeCloud.groups', async () => { + const { cubeEvaluator, compiler } = prepareJsCompiler(` + cube(\`Test\`, { + sql: 'SELECT * FROM users', + dimensions: { + userId: { + sql: \`userId\`, + type: 'string' + } + }, + accessPolicy: [ + { + role: \`*\`, + rowLevel: { + filters: [ + { + member: \`userId\`, + operator: \`equals\`, + values: [ groups ] + } + ] + } + } + ] + }) + `); + + await compiler.compile(); + + const transpiledValues = cubeEvaluator.cubeFromPath('Test').accessPolicy?.[0].rowLevel?.filters?.[0].values; + expect(transpiledValues.toString()).toMatch('securityContext.cubeCloud.groups'); + }); + + it('CubePropContextTranspiler with bare shorthand groups (no array wrap) should transpile to securityContext.cubeCloud.groups', async () => { + const { cubeEvaluator, compiler } = prepareJsCompiler(` + cube(\`Test\`, { + sql: 'SELECT * FROM users', + dimensions: { + userId: { + sql: \`userId\`, + type: 'string' + } + }, + accessPolicy: [ + { + role: \`*\`, + rowLevel: { + filters: [ + { + member: \`userId\`, + operator: \`equals\`, + values: groups + } + ] + } + } + ] + }) + `); + + await compiler.compile(); + + const transpiledValues = cubeEvaluator.cubeFromPath('Test').accessPolicy?.[0].rowLevel?.filters?.[0].values; + expect(transpiledValues.toString()).toMatch('securityContext.cubeCloud.groups'); + }); + + it('CubePropContextTranspiler with shorthand groups member access should transpile to securityContext.cubeCloud.groups', async () => { + const { cubeEvaluator, compiler } = prepareJsCompiler(` + cube(\`Test\`, { + sql: 'SELECT * FROM users', + dimensions: { + userId: { + sql: \`userId\`, + type: 'string' + } + }, + accessPolicy: [ + { + role: \`*\`, + rowLevel: { + filters: [ + { + member: \`userId\`, + operator: \`equals\`, + values: [ groups.someProperty ] + } + ] + } + } + ] + }) + `); + + await compiler.compile(); + + const transpiledValues = cubeEvaluator.cubeFromPath('Test').accessPolicy?.[0].rowLevel?.filters?.[0].values; + expect(transpiledValues.toString()).toMatch('securityContext.cubeCloud.groups.someProperty'); + }); + + it('CubePropContextTranspiler with full path to groups should work normally', async () => { + const { cubeEvaluator, compiler } = prepareJsCompiler(` + cube(\`Test\`, { + sql: 'SELECT * FROM users', + dimensions: { + userId: { + sql: \`userId\`, + type: 'string' + } + }, + accessPolicy: [ + { + role: \`*\`, + rowLevel: { + filters: [ + { + member: \`userId\`, + operator: \`equals\`, + values: [ securityContext.cubeCloud.groups ] + } + ] + } + } + ] + }) + `); + + await compiler.compile(); + + const transpiledValues = cubeEvaluator.cubeFromPath('Test').accessPolicy?.[0].rowLevel?.filters?.[0].values; + expect(transpiledValues.toString()).toMatch('securityContext.cubeCloud.groups'); + }); + + it('CubePropContextTranspiler with groups shorthand in sql template should transpile to SECURITY_CONTEXT.cubeCloud.groups', async () => { + const { cubeEvaluator, compiler } = prepareJsCompiler(` + cube(\`Test\`, { + sql: \`SELECT * FROM users WHERE tenant_id = \${groups}\`, + dimensions: { + userId: { + sql: \`userId\`, + type: 'string' + } + } + }) + `); + + await compiler.compile(); + + const transpiledSql = cubeEvaluator.cubeFromPath('Test').sql; + expect(transpiledSql!.toString()).toMatch('SECURITY_CONTEXT.cubeCloud.groups'); + }); + + it('CubePropContextTranspiler with userAttributes shorthand in dimension sql should transpile to SECURITY_CONTEXT', async () => { + const { cubeEvaluator, compiler } = prepareJsCompiler(` + cube(\`Test\`, { + sql: 'SELECT * FROM users', + dimensions: { + userId: { + sql: \`\${userAttributes.region}\`, + type: 'string' + } + } + }) + `); + + await compiler.compile(); + + const transpiledSql = cubeEvaluator.cubeFromPath('Test').dimensions.userId.sql; + expect(transpiledSql!.toString()).toMatch('SECURITY_CONTEXT.cubeCloud.userAttributes'); + }); + + it('CubePropContextTranspiler should not transform groups shorthand when a cube member named groups exists', async () => { + const { cubeEvaluator, compiler } = prepareJsCompiler(` + cube(\`Test\`, { + sql: 'SELECT * FROM users', + dimensions: { + groups: { + sql: \`groups_col\`, + type: 'string' + }, + filtered: { + sql: \`\${groups}\`, + type: 'string' + } + } + }) + `); + + await compiler.compile(); + + const transpiledSql = cubeEvaluator.cubeFromPath('Test').dimensions.filtered.sql; + expect(transpiledSql!.toString()).not.toMatch('SECURITY_CONTEXT'); + expect(transpiledSql!.toString()).not.toMatch('securityContext'); + expect(transpiledSql!.toString()).toMatch('groups'); + }); + it('ImportExportTranspiler', async () => { const ieTranspiler = new ImportExportTranspiler(); const errorsReport = new ErrorReporter(); diff --git a/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js b/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js index 864fbafbbc2b9..4a94e5645707b 100644 --- a/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js +++ b/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js @@ -186,6 +186,29 @@ module.exports = { }, }; } + if (user === 'sc_test') { + if (password && password !== 'sc_test_password') { + throw new Error(`Password doesn't match for ${user}`); + } + return { + password, + superuser: false, + securityContext: { + cubeCloud: { + userAttributes: { + tenantId: '1', + }, + groups: ['1', '2'], + }, + auth: { + username: 'sc_test', + userAttributes: {}, + roles: [], + groups: [], + }, + }, + }; + } throw new Error(`User "${user}" doesn't exist`); } }; diff --git a/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/security_context_test.js b/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/security_context_test.js new file mode 100644 index 0000000000000..30d3c5dfd37bb --- /dev/null +++ b/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/security_context_test.js @@ -0,0 +1,102 @@ +cube('security_context_test', { + sql: ` + SELECT * FROM line_items + WHERE ${user_attributes.tenantId.filter('id')} + `, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primary_key: true, + }, + price_dim: { + sql: 'price', + type: 'number', + }, + }, + + measures: { + count: { + type: 'count', + }, + total_price: { + sql: 'price', + type: 'sum', + }, + }, +}); + +cube('sc_array_filter_test', { + sql: ` + SELECT * FROM line_items + WHERE ${groups.filter('product_id')} + `, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primary_key: true, + }, + }, + + measures: { + count: { + type: 'count', + }, + }, +}); + +cube('sc_interpolation_test', { + sql: `SELECT * FROM line_items WHERE id > ${user_attributes.tenantId}`, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primary_key: true, + }, + }, + + measures: { + count: { + type: 'count', + }, + }, +}); + +cube('sc_groups_shorthand_test', { + sql_table: 'public.line_items', + + dimensions: { + id: { + sql: 'id', + type: 'number', + primary_key: true, + }, + product_id: { + sql: 'product_id', + type: 'number', + }, + }, + + measures: { + count: { + type: 'count', + }, + }, + + accessPolicy: [ + { + role: '*', + rowLevel: { + filters: [{ + member: 'product_id', + operator: 'equals', + values: groups, + }], + }, + }, + ], +}); diff --git a/packages/cubejs-testing/test/smoke-rbac.test.ts b/packages/cubejs-testing/test/smoke-rbac.test.ts index 22b584c2be4b7..494072a845659 100644 --- a/packages/cubejs-testing/test/smoke-rbac.test.ts +++ b/packages/cubejs-testing/test/smoke-rbac.test.ts @@ -782,6 +782,109 @@ describe('Cube RBAC Engine', () => { }); }); + describe('SECURITY_CONTEXT.cubeCloud features via SQL API', () => { + let connection: PgClient; + + beforeAll(async () => { + connection = await createPostgresClient('sc_test', 'sc_test_password'); + }); + + afterAll(async () => { + await connection.end(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + test('filter with scalar value generates equality (tenantId)', async () => { + const res = await connection.query( + 'SELECT * FROM security_context_test' + ); + expect(res.rows.length).toBe(1); + }); + + test('filter with array value generates IN clause (groups)', async () => { + const res = await connection.query( + 'SELECT * FROM sc_array_filter_test' + ); + expect(res.rows.length).toBeGreaterThan(0); + }); + + test('toString interpolation renders as param in SQL', async () => { + const res = await connection.query( + 'SELECT * FROM sc_interpolation_test' + ); + expect(res.rows.length).toBeGreaterThan(0); + }); + + test('groups shorthand in access policy row filter', async () => { + const res = await connection.query( + 'SELECT * FROM sc_groups_shorthand_test' + ); + expect(res.rows.length).toBeGreaterThan(0); + }); + }); + + describe('SECURITY_CONTEXT.cubeCloud features via REST API', () => { + let scClient: CubeApi; + + const SC_TEST_TOKEN = sign({ + cubeCloud: { + userAttributes: { + tenantId: '1', + }, + groups: ['1', '2'], + }, + auth: { + username: 'sc_test', + userAttributes: {}, + roles: [], + groups: [], + }, + }, DEFAULT_CONFIG.CUBEJS_API_SECRET, { + expiresIn: '2 days' + }); + + beforeAll(async () => { + scClient = cubejs(async () => SC_TEST_TOKEN, { + apiUrl: birdbox.configuration.apiUrl, + }); + }); + + test('filter with scalar value (tenantId) via REST', async () => { + const result = await scClient.load({ + measures: ['security_context_test.count'], + }); + const rows = result.rawData(); + expect(rows.length).toBe(1); + expect(rows[0]['security_context_test.count']).toBeDefined(); + }); + + test('filter with array value (groups) generates IN clause via REST', async () => { + const result = await scClient.load({ + measures: ['sc_array_filter_test.count'], + }); + const rows = result.rawData(); + expect(rows.length).toBe(1); + expect(rows[0]['sc_array_filter_test.count']).toBeDefined(); + }); + + test('toString interpolation via REST', async () => { + const result = await scClient.load({ + measures: ['sc_interpolation_test.count'], + }); + const rows = result.rawData(); + expect(rows.length).toBe(1); + expect(rows[0]['sc_interpolation_test.count']).toBeDefined(); + }); + + test('groups shorthand access policy via REST', async () => { + const result = await scClient.load({ + measures: ['sc_groups_shorthand_test.count'], + }); + const rows = result.rawData(); + expect(rows.length).toBe(1); + expect(rows[0]['sc_groups_shorthand_test.count']).toBeDefined(); + }); + }); + describe('RBAC via REST API', () => { let client: CubeApi; let defaultClient: CubeApi; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs index d2d1bc523348a..d4338852e1cc9 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs @@ -379,6 +379,14 @@ impl NativeMemberSql { ParamValue::StringVec(prop_vec) } else if let Ok(prop) = String::from_native(property_value.clone()) { ParamValue::String(prop) + } else if let Ok(prop) = f64::from_native(property_value.clone()) { + if prop.fract() == 0.0 && prop.is_finite() { + ParamValue::String(format!("{}", prop as i64)) + } else { + ParamValue::String(prop.to_string()) + } + } else if let Ok(prop) = bool::from_native(property_value.clone()) { + ParamValue::String(prop.to_string()) } else if property_value.is_undefined()? || property_value.is_null()? { ParamValue::None } else { @@ -418,15 +426,17 @@ impl NativeMemberSql { .map(|v| Self::process_secutity_context_value(&proxy_state, &v)) .collect::, _>>()?; - let values = values.to_native(context)?; - if let Ok(column) = column.to_function() { - let result = column.call(vec![values])?; + let native_values = values.to_native(context)?; + let result = column.call(vec![native_values])?; if let Ok(result) = result.to_string() { result.value()? } else { "".to_string() } + } else if let Ok(column) = column.to_string() { + let column_value = column.value()?; + format!("{} IN ({})", column_value, values.join(", ")) } else { "".to_string() } @@ -460,6 +470,38 @@ impl NativeMemberSql { Ok(NativeObjectHandle::new(result.into_object())) } + fn security_context_to_string_fn( + context_holder: NativeContextHolder, + property_value: NativeObjectHandle, + proxy_state: ProxyStateWeak, + ) -> Result, CubeError> { + let str_value = if let Ok(prop_vec) = Vec::::from_native(property_value.clone()) { + Some(prop_vec) + } else if let Ok(prop) = String::from_native(property_value.clone()) { + Some(vec![prop]) + } else if let Ok(prop) = f64::from_native(property_value.clone()) { + if prop.fract() == 0.0 && prop.is_finite() { + Some(vec![format!("{}", prop as i64)]) + } else { + Some(vec![prop.to_string()]) + } + } else if let Ok(prop) = bool::from_native(property_value.clone()) { + Some(vec![prop.to_string()]) + } else { + None + }; + let allocated = match str_value { + Some(values) => values + .iter() + .map(|v| Self::process_secutity_context_value(&proxy_state, v)) + .collect::, _>>()? + .join(", "), + None => String::new(), + }; + let result = context_holder.to_string_fn(allocated)?; + Ok(NativeObjectHandle::new(result.into_object())) + } + fn security_context_proxy( context_holder: NativeContextHolder, proxy_state: ProxyStateWeak, @@ -484,8 +526,15 @@ impl NativeMemberSql { } if &prop == "unsafeValue" { return Ok(Some(Self::security_context_unsafe_value_fn( - inner_context, + inner_context.clone(), + target.clone(), + )?)); + } + if &prop == "toString" || &prop == "valueOf" { + return Ok(Some(Self::security_context_to_string_fn( + inner_context.clone(), target.clone(), + proxy_state.clone(), )?)); } let target_obj = target.to_struct()?; @@ -497,32 +546,67 @@ impl NativeMemberSql { property_value, )?)); } + Ok(Some(Self::security_context_leaf_proxy( + inner_context, + proxy_state.clone(), + property_value, + )?)) + }) + } - let result = inner_context.empty_struct()?; - result.set_field( - "filter", - Self::security_context_filter_fn( - inner_context.clone(), - property_value.clone(), - false, - proxy_state.clone(), - )?, - )?; - result.set_field( - "requiredFilter", - Self::security_context_filter_fn( - inner_context.clone(), - property_value.clone(), - true, - proxy_state.clone(), - )?, - )?; - result.set_field( - "unsafeValue", - Self::security_context_unsafe_value_fn(inner_context, target.clone())?, - )?; - let result = NativeObjectHandle::new(result.into_object()); - Ok(Some(result)) + /// Creates a chainable proxy for leaf (non-object) security context values. + /// The proxy target is a struct with method properties (filter, unsafeValue, + /// etc.). Unknown property access returns another chainable proxy, enabling + /// deeply nested paths like `SECURITY_CONTEXT.cubeCloud.tenantId.filter(...)`. + fn security_context_leaf_proxy( + context_holder: NativeContextHolder, + proxy_state: ProxyStateWeak, + property_value: NativeObjectHandle, + ) -> Result, CubeError> { + let result = context_holder.empty_struct()?; + result.set_field( + "filter", + Self::security_context_filter_fn( + context_holder.clone(), + property_value.clone(), + false, + proxy_state.clone(), + )?, + )?; + result.set_field( + "requiredFilter", + Self::security_context_filter_fn( + context_holder.clone(), + property_value.clone(), + true, + proxy_state.clone(), + )?, + )?; + result.set_field( + "unsafeValue", + Self::security_context_unsafe_value_fn(context_holder.clone(), property_value.clone())?, + )?; + result.set_field( + "toString", + Self::security_context_to_string_fn( + context_holder.clone(), + property_value.clone(), + proxy_state.clone(), + )?, + )?; + let methods_handle = NativeObjectHandle::new(result.into_object()); + context_holder.make_proxy(Some(methods_handle), move |inner_context, target, prop| { + if let Ok(target_obj) = target.to_struct() { + if let Ok(true) = target_obj.has_field(&prop) { + return Ok(Some(target_obj.get_field(&prop)?)); + } + } + let undef = inner_context.undefined()?; + Ok(Some(Self::security_context_leaf_proxy( + inner_context, + proxy_state.clone(), + undef, + )?)) }) }