diff --git a/labs/playground1/profile.yaml b/labs/playground1/profile.yaml index 0baf6af7..06cdfd0a 100644 --- a/labs/playground1/profile.yaml +++ b/labs/playground1/profile.yaml @@ -4,3 +4,4 @@ persistent-path: ./test-data/moma.db log-queries: true log-parameters: true + allow: '*' diff --git a/packages/build/src/lib/schema-parser/middleware/checkProfile.ts b/packages/build/src/lib/schema-parser/middleware/checkProfile.ts index 92ec6c8d..77092966 100644 --- a/packages/build/src/lib/schema-parser/middleware/checkProfile.ts +++ b/packages/build/src/lib/schema-parser/middleware/checkProfile.ts @@ -14,19 +14,25 @@ export class CheckProfile extends SchemaParserMiddleware { } public async handle(schemas: RawAPISchema, next: () => Promise) { + if (!schemas.profiles && schemas.profile) { + schemas.profiles = [schemas.profile]; + } + await next(); const transformedSchemas = schemas as APISchema; - if (!transformedSchemas.profile) + if (!transformedSchemas.profiles) throw new Error( `The profile of schema ${transformedSchemas.urlPath} is not defined` ); - try { - this.dataSourceFactory(transformedSchemas.profile); - } catch (e: any) { - throw new Error( - `The profile of schema ${transformedSchemas.urlPath} is invalid: ${e?.message}` - ); + for (const profile of transformedSchemas.profiles) { + try { + this.dataSourceFactory(profile); + } catch (e: any) { + throw new Error( + `The profile ${profile} of schema ${transformedSchemas.urlPath} is invalid: ${e?.message}` + ); + } } } } diff --git a/packages/build/src/lib/schema-parser/middleware/middleware.ts b/packages/build/src/lib/schema-parser/middleware/middleware.ts index 09b51c67..89e931a2 100644 --- a/packages/build/src/lib/schema-parser/middleware/middleware.ts +++ b/packages/build/src/lib/schema-parser/middleware/middleware.ts @@ -24,6 +24,7 @@ export interface RawAPISchema request?: DeepPartial; response?: DeepPartial; metadata?: Record; + profile?: string; } @injectable() diff --git a/packages/build/test/builder/profile.yaml b/packages/build/test/builder/profile.yaml index 925e202b..c8aa0072 100644 --- a/packages/build/test/builder/profile.yaml +++ b/packages/build/test/builder/profile.yaml @@ -1,2 +1,3 @@ - name: test type: pg + allow: '*' diff --git a/packages/build/test/schema-parser/middleware/checkProfile.spec.ts b/packages/build/test/schema-parser/middleware/checkProfile.spec.ts index af2c2d34..cb165699 100644 --- a/packages/build/test/schema-parser/middleware/checkProfile.spec.ts +++ b/packages/build/test/schema-parser/middleware/checkProfile.spec.ts @@ -26,7 +26,7 @@ it('Should throw error when the profile is invalid', async () => { await expect( checkProfile.handle(schema, async () => Promise.resolve()) ).rejects.toThrow( - `The profile of schema /user is invalid: profile not found` + `The profile profile1 of schema /user is invalid: profile not found` ); }); diff --git a/packages/core/src/containers/modules/executor.ts b/packages/core/src/containers/modules/executor.ts index 271b2bc5..cd8f7566 100644 --- a/packages/core/src/containers/modules/executor.ts +++ b/packages/core/src/containers/modules/executor.ts @@ -4,6 +4,7 @@ import { TYPES } from '../types'; import { DataSource, EXTENSION_IDENTIFIER_METADATA_KEY, + EXTENSION_TYPE_METADATA_KEY, } from '../../models/extensions'; import { Profile } from '../../models/profile'; import 'reflect-metadata'; @@ -38,6 +39,13 @@ export const executorModule = (profiles: Map) => // See https://github.com/inversify/InversifyJS/blob/master/src/syntax/constraint_helpers.ts#L32 const constructor = request.parentRequest?.bindings[0] .implementationType as ClassType; + const parentType = Reflect.getMetadata( + EXTENSION_TYPE_METADATA_KEY, + constructor + ); + // Always fulfill the request while the injector isn't a data source + if (parentType !== TYPES.Extension_DataSource) return true; + const dataSourceId = Reflect.getMetadata( EXTENSION_IDENTIFIER_METADATA_KEY, constructor diff --git a/packages/core/src/lib/template-engine/nunjucksExecutionMetadata.ts b/packages/core/src/lib/template-engine/nunjucksExecutionMetadata.ts index 34330883..ead2aa12 100644 --- a/packages/core/src/lib/template-engine/nunjucksExecutionMetadata.ts +++ b/packages/core/src/lib/template-engine/nunjucksExecutionMetadata.ts @@ -32,6 +32,7 @@ export class NunjucksExecutionMetadata { context: { params: this.parameters, user: this.userInfo, + profile: this.profileName, }, [ReservedContextKeys.CurrentProfileName]: this.profileName, }; diff --git a/packages/core/src/models/artifact.ts b/packages/core/src/models/artifact.ts index 6b1b4fcd..76d982c5 100644 --- a/packages/core/src/models/artifact.ts +++ b/packages/core/src/models/artifact.ts @@ -91,7 +91,7 @@ export interface APISchema { // If not set pagination, then API request not provide the field to do it pagination?: PaginationSchema; sample?: Sample; - profile: string; + profiles: Array; } export interface BuiltArtifact { diff --git a/packages/core/src/models/profile.ts b/packages/core/src/models/profile.ts index 00b0ae5e..3a949267 100644 --- a/packages/core/src/models/profile.ts +++ b/packages/core/src/models/profile.ts @@ -4,6 +4,19 @@ // host: example.com // username: vulcan // password: xxxx +// allow: +// - name: admin + +export type ProfileAllowConstraints = + // allow: * + | string + // allow: + // name: admin + | Record + // allow: + // - admin + // - name: admin + | Array>; export interface Profile> { /** This unique name of this profile */ @@ -12,4 +25,6 @@ export interface Profile> { type: string; /** Connection info, which depends on drivers */ connection?: C; + /** What users have access to this profile */ + allow: ProfileAllowConstraints; } diff --git a/packages/core/test/containers/executor.spec.ts b/packages/core/test/containers/executor.spec.ts index 1ad1eac4..5de51975 100644 --- a/packages/core/test/containers/executor.spec.ts +++ b/packages/core/test/containers/executor.spec.ts @@ -1,4 +1,6 @@ import { + DataResult, + DataSource, executorModule, Profile, TYPES, @@ -6,16 +8,34 @@ import { } from '@vulcan-sql/core'; import { Container, injectable, interfaces, multiInject } from 'inversify'; -@injectable() @VulcanExtensionId('ds1') -class DataSource1 { - constructor(@multiInject(TYPES.Profile) public profiles: Profile[]) {} +class DataSource1 extends DataSource { + @multiInject(TYPES.Profile) public injectedProfiles!: Profile[]; + public execute(): Promise { + throw new Error('Method not implemented.'); + } + + public prepare(): Promise { + throw new Error('Method not implemented.'); + } } -@injectable() @VulcanExtensionId('ds2') -class DataSource2 { - constructor(@multiInject(TYPES.Profile) public profiles: Profile[]) {} +class DataSource2 extends DataSource { + @multiInject(TYPES.Profile) public injectedProfiles!: Profile[]; + public execute(): Promise { + throw new Error('Method not implemented.'); + } + + public prepare(): Promise { + throw new Error('Method not implemented.'); + } +} + +@VulcanExtensionId('ds3') +@injectable() +class DataSource3 { + @multiInject(TYPES.Profile) public injectedProfiles!: Profile[]; } it('Executor module should bind correct profiles to data sources and create a factory which return proper data sources', async () => { @@ -30,9 +50,11 @@ it('Executor module should bind correct profiles to data sources and create a fa .to(DataSource2) .whenTargetNamed('ds2'); const profiles = new Map(); - profiles.set('p1', { name: 'p1', type: 'ds1' }); - profiles.set('p2', { name: 'p2', type: 'ds1' }); - profiles.set('p3', { name: 'p3', type: 'ds2' }); + profiles.set('p1', { name: 'p1', type: 'ds1', allow: '*' }); + profiles.set('p2', { name: 'p2', type: 'ds1', allow: '*' }); + profiles.set('p3', { name: 'p3', type: 'ds2', allow: '*' }); + container.bind(TYPES.ExtensionConfig).toConstantValue({}); + container.bind(TYPES.ExtensionName).toConstantValue(''); await container.loadAsync(executorModule(profiles)); const factory = container.get>( TYPES.Factory_DataSource @@ -47,11 +69,13 @@ it('Executor module should bind correct profiles to data sources and create a fa expect(dsFromP1 instanceof DataSource1).toBeTruthy(); expect(dsFromP2 instanceof DataSource1).toBeTruthy(); expect(dsFromP3 instanceof DataSource2).toBeTruthy(); - expect(dsFromP1.profiles).toEqual([ - { name: 'p1', type: 'ds1' }, - { name: 'p2', type: 'ds1' }, + expect(dsFromP1.injectedProfiles).toEqual([ + { name: 'p1', type: 'ds1', allow: '*' }, + { name: 'p2', type: 'ds1', allow: '*' }, + ]); + expect(dsFromP3.injectedProfiles).toEqual([ + { name: 'p3', type: 'ds2', allow: '*' }, ]); - expect(dsFromP3.profiles).toEqual([{ name: 'p3', type: 'ds2' }]); }); it('Data source factory should throw error with invalid profile name', async () => { @@ -59,6 +83,8 @@ it('Data source factory should throw error with invalid profile name', async () const container = new Container(); const profiles = new Map(); await container.loadAsync(executorModule(profiles)); + container.bind(TYPES.ExtensionConfig).toConstantValue({}); + container.bind(TYPES.ExtensionName).toConstantValue(''); const factory = container.get>( TYPES.Factory_DataSource ); @@ -67,3 +93,21 @@ it('Data source factory should throw error with invalid profile name', async () `Profile some-invalid-profile not found` ); }); + +it('When the requestor is not a data source, container should return all profiles', async () => { + // Arrange + const container = new Container(); + const profiles = new Map(); + profiles.set('p1', { name: 'p1', type: 'ds1', allow: '*' }); + profiles.set('p2', { name: 'p2', type: 'ds1', allow: '*' }); + profiles.set('p3', { name: 'p3', type: 'ds2', allow: '*' }); + container.bind(TYPES.Extension_DataSource).to(DataSource3); + await container.loadAsync(executorModule(profiles)); + + // Act + const ds3 = container.get(TYPES.Extension_DataSource); + const profilesInjected = ds3.injectedProfiles; + + // Assert + expect(profilesInjected.length).toEqual(3); +}); diff --git a/packages/core/test/data-source/dataSource.spec.ts b/packages/core/test/data-source/dataSource.spec.ts index 9348f612..2c602273 100644 --- a/packages/core/test/data-source/dataSource.spec.ts +++ b/packages/core/test/data-source/dataSource.spec.ts @@ -30,10 +30,12 @@ it(`GetProfiles function should return all profiles which belong to us`, async ( { name: 'profile1', type: 'mock', + allow: '*', }, { name: 'profile2', type: 'mock', + allow: '*', }, ]); // Act @@ -48,10 +50,12 @@ it(`GetProfile function should correct profile`, async () => { { name: 'profile1', type: 'mock', + allow: '*', }, { name: 'profile2', type: 'mock', + allow: '*', }, ]); // Act diff --git a/packages/extension-driver-duckdb/tests/duckdbDataSource.spec.ts b/packages/extension-driver-duckdb/tests/duckdbDataSource.spec.ts index c234a0a7..40cf9501 100644 --- a/packages/extension-driver-duckdb/tests/duckdbDataSource.spec.ts +++ b/packages/extension-driver-duckdb/tests/duckdbDataSource.spec.ts @@ -44,7 +44,7 @@ afterAll(async () => { it('Should work with memory-only database', async () => { // Arrange const dataSource = new DuckDBDataSource(null as any, 'duckdb', [ - { name: 'mocked-profile', type: 'duck' }, + { name: 'mocked-profile', type: 'duck', allow: '*' }, ]); // set connection to undefined, test the tolerance await dataSource.activate(); const bindParams = new Map(); @@ -87,6 +87,7 @@ it('Should work with persistent database', async () => { connection: { 'persistent-path': testFile, }, + allow: '*', }, ]); await dataSource.activate(); @@ -126,6 +127,7 @@ it('Should send correct data with chunks', async () => { connection: { 'persistent-path': testFile, }, + allow: '*', }, ]); await dataSource.activate(); @@ -163,6 +165,7 @@ it('Should throw error from upstream', async () => { connection: { 'persistent-path': testFile, }, + allow: '*', }, ]); await dataSource.activate(); @@ -187,6 +190,7 @@ it('Should return empty data and column with zero result', async () => { connection: { 'persistent-path': testFile, }, + allow: '*', }, ]); await dataSource.activate(); @@ -215,6 +219,7 @@ it('Should print queries without binding when log-queries = true', async () => { connection: { 'log-queries': true, }, + allow: '*', }, ]); await dataSource.activate(); @@ -256,6 +261,7 @@ it('Should print queries with binding when log-queries = true and log-parameters 'log-queries': true, 'log-parameters': true, }, + allow: '*', }, ]); await dataSource.activate(); @@ -297,6 +303,7 @@ it('Should share db instances for same path besides in-memory only db', async () connection: { 'persistent-path': path.resolve(__dirname, 'db1.db'), }, + allow: '*', }, { name: 'db2', @@ -304,6 +311,7 @@ it('Should share db instances for same path besides in-memory only db', async () connection: { 'persistent-path': path.resolve(__dirname, 'db1.db'), }, + allow: '*', }, { name: 'db3', @@ -311,16 +319,19 @@ it('Should share db instances for same path besides in-memory only db', async () connection: { 'persistent-path': path.resolve(__dirname, 'db2.db'), }, + allow: '*', }, { name: 'db4', type: 'duck', connection: {}, + allow: '*', }, { name: 'db5', type: 'duck', connection: {}, + allow: '*', }, ]); await dataSource.activate(); @@ -363,6 +374,7 @@ it('Should throw error when profile instance not found', async () => { { name: 'mocked-profile', type: 'duck', + allow: '*', }, ]); await dataSource.activate(); diff --git a/packages/integration-testing/src/example1/profile.yaml b/packages/integration-testing/src/example1/profile.yaml index 15bc4015..3646563b 100644 --- a/packages/integration-testing/src/example1/profile.yaml +++ b/packages/integration-testing/src/example1/profile.yaml @@ -1,2 +1,3 @@ - name: pg-mem type: pg-mem + allow: '*' diff --git a/packages/serve/src/containers/container.ts b/packages/serve/src/containers/container.ts index a4d4e6ed..f27dcbd9 100644 --- a/packages/serve/src/containers/container.ts +++ b/packages/serve/src/containers/container.ts @@ -3,6 +3,7 @@ import { Container as CoreContainer } from '@vulcan-sql/core'; import { applicationModule, documentRouterModule, + evaluationModule, extensionModule, routeGeneratorModule, } from './modules'; @@ -32,6 +33,7 @@ export class Container { await this.inversifyContainer.loadAsync(extensionModule(config)); await this.inversifyContainer.loadAsync(applicationModule()); await this.inversifyContainer.loadAsync(documentRouterModule()); + await this.inversifyContainer.loadAsync(evaluationModule()); } public async unload() { diff --git a/packages/serve/src/containers/modules/evaluation.ts b/packages/serve/src/containers/modules/evaluation.ts new file mode 100644 index 00000000..450afc4a --- /dev/null +++ b/packages/serve/src/containers/modules/evaluation.ts @@ -0,0 +1,8 @@ +import { Evaluator } from '@vulcan-sql/serve/evaluator'; +import { AsyncContainerModule } from 'inversify'; +import { TYPES } from '../types'; + +export const evaluationModule = () => + new AsyncContainerModule(async (bind) => { + bind(TYPES.Evaluator).to(Evaluator).inSingletonScope(); + }); diff --git a/packages/serve/src/containers/modules/index.ts b/packages/serve/src/containers/modules/index.ts index 277f46dc..d119f8fa 100644 --- a/packages/serve/src/containers/modules/index.ts +++ b/packages/serve/src/containers/modules/index.ts @@ -2,3 +2,4 @@ export * from './routeGenerator'; export * from './extension'; export * from './application'; export * from './documentRouter'; +export * from './evaluation'; diff --git a/packages/serve/src/containers/types.ts b/packages/serve/src/containers/types.ts index f6ae6c7a..4111882f 100644 --- a/packages/serve/src/containers/types.ts +++ b/packages/serve/src/containers/types.ts @@ -10,6 +10,8 @@ export const TYPES = { VulcanApplication: Symbol.for('VulcanApplication'), // Document router Factory_DocumentRouter: Symbol.for('Factory_DocumentRouter'), + // Evaluation + Evaluator: Symbol.for('Evaluator'), // Extensions Extension_RouteMiddleware: Symbol.for('Extension_RouteMiddleware'), Extension_Authenticator: Symbol.for('Extension_Authenticator'), diff --git a/packages/serve/src/lib/evaluator/constraints/base.ts b/packages/serve/src/lib/evaluator/constraints/base.ts new file mode 100644 index 00000000..ec3a1952 --- /dev/null +++ b/packages/serve/src/lib/evaluator/constraints/base.ts @@ -0,0 +1,5 @@ +import { AuthUserInfo } from '@vulcan-sql/serve/models'; + +export interface AuthConstraint { + evaluate(user: AuthUserInfo): boolean; +} diff --git a/packages/serve/src/lib/evaluator/constraints/helpers.ts b/packages/serve/src/lib/evaluator/constraints/helpers.ts new file mode 100644 index 00000000..728e99cb --- /dev/null +++ b/packages/serve/src/lib/evaluator/constraints/helpers.ts @@ -0,0 +1,8 @@ +export const escapeRegExp = (s: string) => { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +}; + +export const getRegexpFromWildcardPattern = (wildcardString: string) => { + const escapedString = escapeRegExp(wildcardString); + return new RegExp('^' + escapedString.replace(/\\\*/g, '.*') + '$'); +}; diff --git a/packages/serve/src/lib/evaluator/constraints/index.ts b/packages/serve/src/lib/evaluator/constraints/index.ts new file mode 100644 index 00000000..3f3494c6 --- /dev/null +++ b/packages/serve/src/lib/evaluator/constraints/index.ts @@ -0,0 +1,4 @@ +export * from './userName'; +export * from './base'; +export * from './helpers'; +export * from './userAttributes'; diff --git a/packages/serve/src/lib/evaluator/constraints/userAttributes.ts b/packages/serve/src/lib/evaluator/constraints/userAttributes.ts new file mode 100644 index 00000000..dc0ed7a6 --- /dev/null +++ b/packages/serve/src/lib/evaluator/constraints/userAttributes.ts @@ -0,0 +1,46 @@ +import { AuthUserInfo } from '@vulcan-sql/serve/models'; +import { AuthConstraint } from './base'; +import { getRegexpFromWildcardPattern } from './helpers'; + +export class UserAttributesConstrain implements AuthConstraint { + constructor(private attributes: Record) {} + + public evaluate(user: AuthUserInfo): boolean { + // user should have all attribute to pass the evaluation. + // allow: + // attributes: + // group: admin + // enabled: true + // --> group = 'admin AND enabled = 'true' + for (const attributeName in this.attributes) { + if ( + !this.doesUserHasAttributeWithValue( + user, + attributeName, + String(this.attributes[attributeName]) + ) + ) + return false; + } + return true; + } + + private doesUserHasAttributeWithValue( + user: AuthUserInfo, + attributeName: string, + attributeValue: string + ): boolean { + for (const userAttributeName in user.attr) { + if ( + // attribute name passes the pattern + getRegexpFromWildcardPattern(attributeName).test(userAttributeName) && + // attribute value passes the pattern + getRegexpFromWildcardPattern(attributeValue).test( + String(user.attr[userAttributeName]) + ) + ) + return true; + } + return false; + } +} diff --git a/packages/serve/src/lib/evaluator/constraints/userName.ts b/packages/serve/src/lib/evaluator/constraints/userName.ts new file mode 100644 index 00000000..940a1d2c --- /dev/null +++ b/packages/serve/src/lib/evaluator/constraints/userName.ts @@ -0,0 +1,11 @@ +import { AuthUserInfo } from '@vulcan-sql/serve/models'; +import { AuthConstraint } from './base'; +import { getRegexpFromWildcardPattern } from './helpers'; + +export class UserNameConstrain implements AuthConstraint { + constructor(private nameRule: string) {} + + public evaluate(user: AuthUserInfo): boolean { + return getRegexpFromWildcardPattern(this.nameRule).test(user.name); + } +} diff --git a/packages/serve/src/lib/evaluator/evaluator.ts b/packages/serve/src/lib/evaluator/evaluator.ts new file mode 100644 index 00000000..61e938f8 --- /dev/null +++ b/packages/serve/src/lib/evaluator/evaluator.ts @@ -0,0 +1,125 @@ +import { + getLogger, + Profile, + ProfileAllowConstraints, + TYPES as CORE_TYPES, +} from '@vulcan-sql/core'; +import { injectable, multiInject, optional } from 'inversify'; +import { isArray } from 'lodash'; +import { AuthUserInfo } from '../../models/extensions'; +import { + AuthConstraint, + UserAttributesConstrain, + UserNameConstrain, +} from './constraints'; + +// Single allow condition are combined with AND logic +// Only the user who has name admin and has group attribute with value admin can access this profile. +// - name: 'pg-admin' +// driver: 'pg' +// connection: xx +// allow: +// - name: admin +// attributes: +// name: group +// value: admin +// +// Multiple allow conditions are combined with OR logic. +// admin, someoneelse, and those who have group attribute with value admin can access this profile. +// - name: 'pg-admin' +// driver: 'pg' +// connection: xx +// allow: +// - name: admin +// - name: someoneelse +// - attributes: +// name: group +// value: admin + +const logger = getLogger({ scopeName: 'SERVE' }); + +@injectable() +export class Evaluator { + private profiles = new Map(); + + constructor( + @multiInject(CORE_TYPES.Profile) @optional() profiles: Profile[] = [] + ) { + for (const profile of profiles) { + if (!profile.allow) { + logger.warn( + `Profile ${profile.name} doesn't have allow property, which means nobody can use it` + ); + continue; + } + this.profiles.set(profile.name, this.getConstraints(profile.allow)); + } + } + + public evaluateProfile( + user: AuthUserInfo, + candidates: string[] + ): string | null { + for (const candidate of candidates) { + const orConstraints = this.profiles.get(candidate); + if (!orConstraints) + throw new Error( + `Profile candidate ${candidate} doesn't have any rule.` + ); + const isQualified = this.evaluateOrConstraints(user, orConstraints); + if (isQualified) return candidate; + } + return null; + } + + private evaluateOrConstraints( + user: AuthUserInfo, + orConstraints: AuthConstraint[][] + ): boolean { + for (const constraints of orConstraints) { + if (this.evaluateAndConstraints(user, constraints)) return true; + } + return false; + } + + private evaluateAndConstraints( + user: AuthUserInfo, + andConstraints: AuthConstraint[] + ): boolean { + for (const constraint of andConstraints) { + if (!constraint.evaluate(user || { name: '', attr: {} })) return false; + } + return true; + } + + private getConstraints(allow: ProfileAllowConstraints): AuthConstraint[][] { + const orConstraints: AuthConstraint[][] = []; + const rules: Record[] = []; + // allow: admin or allow: * + if (typeof allow === 'string') rules.push({ name: allow }); + // allow: + // name: admin + else if (!isArray(allow)) rules.push(allow); + else { + allow.forEach((rule) => { + // allow: + // - * + if (typeof rule === 'string') rules.push({ name: rule }); + // allow: + // - name: admin + else rules.push(rule); + }); + } + + for (const rule of rules) { + const andConstraints: AuthConstraint[] = []; + if (rule['name']) + andConstraints.push(new UserNameConstrain(rule['name'])); + if (rule['attributes']) + andConstraints.push(new UserAttributesConstrain(rule['attributes'])); + orConstraints.push(andConstraints); + } + + return orConstraints; + } +} diff --git a/packages/serve/src/lib/evaluator/index.ts b/packages/serve/src/lib/evaluator/index.ts new file mode 100644 index 00000000..6ca009c6 --- /dev/null +++ b/packages/serve/src/lib/evaluator/index.ts @@ -0,0 +1 @@ +export * from './evaluator'; diff --git a/packages/serve/src/lib/route/route-component/baseRoute.ts b/packages/serve/src/lib/route/route-component/baseRoute.ts index 697e2bed..e7f28eb4 100644 --- a/packages/serve/src/lib/route/route-component/baseRoute.ts +++ b/packages/serve/src/lib/route/route-component/baseRoute.ts @@ -3,6 +3,7 @@ import { APISchema, TemplateEngine, Pagination } from '@vulcan-sql/core'; import { IRequestValidator } from './requestValidator'; import { IRequestTransformer, RequestParameters } from './requestTransformer'; import { IPaginationTransformer } from './paginationTransformer'; +import { Evaluator } from '@vulcan-sql/serve/evaluator'; export interface TransformedRequest { reqParams: RequestParameters; @@ -15,6 +16,7 @@ export interface RouteOptions { reqValidator: IRequestValidator; paginationTransformer: IPaginationTransformer; templateEngine: TemplateEngine; + evaluator: Evaluator; } export interface IRoute { @@ -27,19 +29,23 @@ export abstract class BaseRoute implements IRoute { protected readonly reqValidator: IRequestValidator; protected readonly templateEngine: TemplateEngine; protected readonly paginationTransformer: IPaginationTransformer; + private evaluator: Evaluator; + // TODO: Too many injection from constructor, we should try to use container or compose some components constructor({ apiSchema, reqTransformer, reqValidator, paginationTransformer, templateEngine, + evaluator, }: RouteOptions) { this.apiSchema = apiSchema; this.reqTransformer = reqTransformer; this.reqValidator = reqValidator; this.paginationTransformer = paginationTransformer; this.templateEngine = templateEngine; + this.evaluator = evaluator; } public abstract respond(ctx: KoaContext): Promise; @@ -49,7 +55,12 @@ export abstract class BaseRoute implements IRoute { protected async handle(user: AuthUserInfo, transformed: TransformedRequest) { const { reqParams } = transformed; // could template name or template path, use for template engine - const { templateSource, profile } = this.apiSchema; + const { templateSource, profiles } = this.apiSchema; + + const profile = this.evaluator.evaluateProfile(user, profiles); + if (!profile) + // Should be 403 + throw new Error(`Forbidden`); const result = await this.templateEngine.execute(templateSource, { parameters: reqParams, diff --git a/packages/serve/src/lib/route/routeGenerator.ts b/packages/serve/src/lib/route/routeGenerator.ts index 6c2610e6..60d428c7 100644 --- a/packages/serve/src/lib/route/routeGenerator.ts +++ b/packages/serve/src/lib/route/routeGenerator.ts @@ -9,6 +9,7 @@ import { import { inject, injectable } from 'inversify'; import { TYPES as CORE_TYPES } from '@vulcan-sql/core'; import { TYPES } from '../../containers/types'; +import { Evaluator } from '../evaluator'; export enum APIProviderType { RESTFUL = 'RESTFUL', @@ -27,6 +28,7 @@ export class RouteGenerator { private reqTransformer: IRequestTransformer; private paginationTransformer: IPaginationTransformer; private templateEngine: TemplateEngine; + private evaluator: Evaluator; private apiOptions: APIRouteBuilderOption = { [APIProviderType.RESTFUL]: RestfulRoute, [APIProviderType.GRAPHQL]: GraphQLRoute, @@ -37,12 +39,14 @@ export class RouteGenerator { @inject(TYPES.RequestValidator) reqValidator: IRequestValidator, @inject(TYPES.PaginationTransformer) paginationTransformer: IPaginationTransformer, - @inject(CORE_TYPES.TemplateEngine) templateEngine: TemplateEngine + @inject(CORE_TYPES.TemplateEngine) templateEngine: TemplateEngine, + @inject(TYPES.Evaluator) evaluator: Evaluator ) { this.reqValidator = reqValidator; this.reqTransformer = reqTransformer; this.paginationTransformer = paginationTransformer; this.templateEngine = templateEngine; + this.evaluator = evaluator; } public async generate(apiSchema: APISchema, optionType: APIProviderType) { @@ -55,6 +59,7 @@ export class RouteGenerator { reqValidator: this.reqValidator, paginationTransformer: this.paginationTransformer, templateEngine: this.templateEngine, + evaluator: this.evaluator, }); } diff --git a/packages/serve/test/app.spec.ts b/packages/serve/test/app.spec.ts index 759eada7..9e6b1fef 100644 --- a/packages/serve/test/app.spec.ts +++ b/packages/serve/test/app.spec.ts @@ -30,15 +30,18 @@ import { KoaContext } from '@vulcan-sql/serve/models'; import { Container } from 'inversify'; import { extensionModule } from '../src/containers/modules'; import { TYPES } from '@vulcan-sql/serve'; +import { Evaluator } from '@vulcan-sql/serve/evaluator'; describe('Test vulcan server for practicing middleware', () => { let container: Container; let stubTemplateEngine: sinon.StubbedInstance; let stubDataSource: sinon.StubbedInstance; + let stubEvaluator: sinon.StubbedInstance; beforeEach(async () => { container = new Container(); stubTemplateEngine = sinon.stubInterface(); stubDataSource = sinon.stubInterface(); + stubEvaluator = sinon.stubInterface(); await container.loadAsync( coreExtensionModule({ @@ -82,6 +85,7 @@ describe('Test vulcan server for practicing middleware', () => { router: [], }) ); + container.bind(TYPES.Evaluator).toConstantValue(stubEvaluator); }); afterEach(() => { @@ -122,6 +126,7 @@ describe('Test vulcan server for calling restful APIs', () => { let container: Container; let stubTemplateEngine: sinon.StubbedInstance; let stubDataSource: sinon.StubbedInstance; + let stubEvaluator: sinon.StubbedInstance; let server: http.Server; const fakeSchemas: Array = [ { @@ -274,6 +279,7 @@ describe('Test vulcan server for calling restful APIs', () => { container = new Container(); stubTemplateEngine = sinon.stubInterface(); stubDataSource = sinon.stubInterface(); + stubEvaluator = sinon.stubInterface(); stubTemplateEngine.execute.callsFake(async (_: string, data: any) => { return { @@ -282,6 +288,8 @@ describe('Test vulcan server for calling restful APIs', () => { }; }); + stubEvaluator.evaluateProfile.returns('profile1'); + await container.loadAsync( coreExtensionModule({ artifact: {} as any, @@ -321,6 +329,7 @@ describe('Test vulcan server for calling restful APIs', () => { router: [], }) ); + container.bind(TYPES.Evaluator).toConstantValue(stubEvaluator); }); afterEach(() => { diff --git a/packages/serve/test/evaluator/evaluator.spec.ts b/packages/serve/test/evaluator/evaluator.spec.ts new file mode 100644 index 00000000..c5145518 --- /dev/null +++ b/packages/serve/test/evaluator/evaluator.spec.ts @@ -0,0 +1,209 @@ +import { Evaluator } from '@vulcan-sql/serve/evaluator'; + +it('Should evaluate user name with wildcard supported', async () => { + // Arrange + const evaluator = new Evaluator([ + { + name: 'profile1', + type: 'mock', + allow: [{ name: 'admin' }, { name: 'ivan*' }, { name: 'fre*da' }], + }, + ]); + // Act, Assert + expect( + evaluator.evaluateProfile({ name: 'admin', attr: {} }, ['profile1']) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: 'ivan12345', attr: {} }, ['profile1']) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: 'freda', attr: {} }, ['profile1']) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: 'fre12345da', attr: {} }, ['profile1']) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: '12345ivan', attr: {} }, ['profile1']) + ).toBe(null); +}); + +it('Should evaluate user attribute with wildcard supported', async () => { + // Arrange + const evaluator = new Evaluator([ + { + name: 'profile1', + type: 'mock', + allow: [ + { attributes: { group: 'admin' } }, + { attributes: { group: 'admin*', enabled: true } }, + { attributes: { 'group*': 'admin' } }, + ], + }, + ]); + // Act, Assert + expect( + evaluator.evaluateProfile({ name: 'admin', attr: { group: 'admin' } }, [ + 'profile1', + ]) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile( + { name: 'admin', attr: { group: 'admin12345', enabled: true } }, + ['profile1'] + ) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile( + { name: 'admin', attr: { group: 'admin12345' } }, + ['profile1'] + ) + ).toBe(null); + expect( + evaluator.evaluateProfile( + { name: 'admin', attr: { group: 'qqq', group1: 'qqq', group2: 'admin' } }, + ['profile1'] + ) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile( + { + name: 'admin', + attr: { group: 'qqq', group1: 'qqq', group2: 'admin123' }, + }, + ['profile1'] + ) + ).toBe(null); +}); + +it('Allow constraints can be a string', async () => { + // Arrange + const evaluator = new Evaluator([ + { + name: 'profile1', + type: 'mock', + allow: 'admin', + }, + ]); + // Act, Assert + expect( + evaluator.evaluateProfile({ name: 'admin', attr: {} }, ['profile1']) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: '12345ivan', attr: {} }, ['profile1']) + ).toBe(null); +}); + +it('Allow constraints can be a string array', async () => { + // Arrange + const evaluator = new Evaluator([ + { + name: 'profile1', + type: 'mock', + allow: ['admin', { name: 'ivan' }], + }, + ]); + // Act, Assert + expect( + evaluator.evaluateProfile({ name: 'admin', attr: {} }, ['profile1']) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: 'ivan', attr: {} }, ['profile1']) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: '12345ivan', attr: {} }, ['profile1']) + ).toBe(null); +}); + +it('Allow constraints can be a object', async () => { + // Arrange + const evaluator = new Evaluator([ + { + name: 'profile1', + type: 'mock', + allow: { name: 'ivan' }, + }, + ]); + // Act, Assert + expect( + evaluator.evaluateProfile({ name: 'ivan', attr: {} }, ['profile1']) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: '12345ivan', attr: {} }, ['profile1']) + ).toBe(null); +}); + +it('Should throw error with invalid profile', async () => { + // Arrange + const evaluator = new Evaluator([]); + // Act, Assert + expect(() => + evaluator.evaluateProfile({ name: 'ivan', attr: {} }, ['profile1']) + ).toThrow(`Profile candidate profile1 doesn't have any rule.`); +}); + +it('Multiple constraints in single rule should be combined with AND logic', async () => { + // Arrange + const evaluator = new Evaluator([ + { + name: 'profile1', + type: 'mock', + allow: { + name: 'admin', + attributes: { + enabled: true, + }, + }, + }, + ]); + // Act, Assert + expect( + evaluator.evaluateProfile({ name: 'admin', attr: { enabled: true } }, [ + 'profile1', + ]) + ).toBe('profile1'); + expect( + evaluator.evaluateProfile({ name: 'admin', attr: { enabled: false } }, [ + 'profile1', + ]) + ).toBe(null); + expect( + evaluator.evaluateProfile({ name: 'admin123', attr: { enabled: true } }, [ + 'profile1', + ]) + ).toBe(null); +}); + +it('Should return first matched profile', async () => { + // Arrange + const evaluator = new Evaluator([ + { + name: 'profile1', + type: 'mock', + allow: { + name: 'admin123', + }, + }, + { + name: 'profile2', + type: 'mock', + allow: { + name: 'admin', + }, + }, + { + name: 'profile3', + type: 'mock', + allow: { + name: 'admin', + }, + }, + ]); + // Act, Assert + expect( + evaluator.evaluateProfile({ name: 'admin', attr: {} }, [ + 'profile1', + 'profile2', + 'profile3', + ]) + ).toBe('profile2'); +}); diff --git a/packages/serve/test/evaluator/helpers.spec.ts b/packages/serve/test/evaluator/helpers.spec.ts new file mode 100644 index 00000000..b809d715 --- /dev/null +++ b/packages/serve/test/evaluator/helpers.spec.ts @@ -0,0 +1,18 @@ +import { getRegexpFromWildcardPattern } from '@vulcan-sql/serve/evaluator/constraints'; + +describe('Test for wildcard pattern', () => { + it.each([ + ['admin', ['admin'], ['1admin', 'admin1', 'ad*min']], + ['admin*', ['admin', 'admin1'], ['1admin', 'ad*min']], + ['*admin', ['admin', '1admin'], ['admin1', 'ad*min']], + ['ad*min', ['admin', 'ad1min'], ['1admin', 'admin1']], + ['admin*[.+', ['admin[.+', 'admin123[.+'], ['admin', 'admin1']], + ])(`test for pattern %p`, (pattern, accept, deny) => { + accept.forEach((c) => + expect(getRegexpFromWildcardPattern(pattern).test(c)).toBe(true) + ); + deny.forEach((c) => + expect(getRegexpFromWildcardPattern(pattern).test(c)).toBe(false) + ); + }); +}); diff --git a/packages/serve/test/route/routeGenerator.spec.ts b/packages/serve/test/route/routeGenerator.spec.ts index 59076d66..0e7a4108 100644 --- a/packages/serve/test/route/routeGenerator.spec.ts +++ b/packages/serve/test/route/routeGenerator.spec.ts @@ -17,6 +17,7 @@ import { } from '@vulcan-sql/serve/route'; import { Container } from 'inversify'; import { TYPES } from '@vulcan-sql/serve/containers'; +import { Evaluator } from '@vulcan-sql/serve/evaluator'; describe('Test route generator ', () => { let container: Container; @@ -25,6 +26,7 @@ describe('Test route generator ', () => { let stubPaginationTransformer: sinon.StubbedInstance; let stubTemplateEngine: sinon.StubbedInstance; let stubDataSource: sinon.StubbedInstance; + let stubEvaluator: sinon.StubbedInstance; const fakeSchemas: Array = Array( faker.datatype.number({ min: 2, max: 4 }) ).fill(sinon.stubInterface()); @@ -38,6 +40,7 @@ describe('Test route generator ', () => { stubPaginationTransformer = sinon.stubInterface(); stubDataSource = sinon.stubInterface(); stubTemplateEngine = sinon.stubInterface(); + stubEvaluator = sinon.stubInterface(); container .bind(TYPES.PaginationTransformer) @@ -53,6 +56,7 @@ describe('Test route generator ', () => { .bind(CORE_TYPES.Factory_DataSource) .toConstantValue(() => stubDataSource); container.bind(TYPES.RouteGenerator).to(RouteGenerator); + container.bind(TYPES.Evaluator).toConstantValue(stubEvaluator); }); afterEach(() => { @@ -76,6 +80,7 @@ describe('Test route generator ', () => { templateEngine: container.get( CORE_TYPES.TemplateEngine ), + evaluator: container.get(TYPES.Evaluator), }); // Act @@ -111,6 +116,7 @@ describe('Test route generator ', () => { templateEngine: container.get( CORE_TYPES.TemplateEngine ), + evaluator: container.get(TYPES.Evaluator), }); // Act diff --git a/tsconfig.base.json b/tsconfig.base.json index 9baea38a..a405ca3a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -102,6 +102,8 @@ "@vulcan-sql/serve/types": ["packages/serve/src/containers/types"], "@vulcan-sql/serve/utils": ["packages/serve/src/lib/utils/index"], "@vulcan-sql/serve/utils/*": ["packages/serve/src/lib/utils/*"], + "@vulcan-sql/serve/evaluator": ["packages/serve/src/lib/evaluator/index"], + "@vulcan-sql/serve/evaluator/*": ["packages/serve/src/lib/evaluator/*"], "@vulcan-sql/test-utility": ["packages/test-utility/src/index"] } },