Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions packages/member-base-nestjs-module/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,37 @@ With `enableGlobalGuard` on (the default) and no `casbinAdapterOptions`, there i

Turning `enableGlobalGuard` off inverts this: the guard returns before any of those checks, so `@AllowActions()` routes are all **allowed** rather than all denied. Decorate deliberately if you take that route.

### Keeping two applications' policies apart (casbinRuleEntity)

Policies live in one table, `casbin_rule`. Two applications pointed at the same database therefore share it — and since the usual startup routine is "load the authoritative rules, clear the table, write them back", each one's boot briefly empties the table the other is enforcing against. Convergence at the end is not the same as isolation during.

`casbinRuleEntity` gives each application its own table. Subclass typeorm-adapter's `CasbinRule` and name the table:

```typescript
// casbin-rule.entity.ts
import { Entity } from 'typeorm';
import { CasbinRule } from 'typeorm-adapter';

@Entity('backend_casbin_rule')
export class BackendCasbinRule extends CasbinRule {}

// app.module.ts
MemberBaseModule.forRoot({
casbinAdapterOptions: { type: 'postgres' /* ... */ },
casbinRuleEntity: BackendCasbinRule,
});
```

Left unset, the adapter is constructed exactly as before and keeps using `casbin_rule`, so this changes nothing for an existing deployment. Setting it on an application that already has policies starts that application from an empty table — it does not migrate the rows.

Three caveats worth stating plainly.

**Splitting the table separates the *cache*, not the permissions.** If both applications rebuild their policies from the same upstream tables, both still end up with identical contents, and a permission missing from those upstream tables stays missing in both.

**The entity is more than a table name.** typeorm-adapter constructs every policy row from it, resolves the repository through it, and — on the branch where it opens the connection itself, where `synchronize` defaults to on — creates the table from it. So it has to keep the `ptype` and `v0`–`v5` columns the adapter reads and writes, which is exactly what subclassing `CasbinRule` guarantees. Columns of your own on top are supported; `@CreateDateColumn()` and `@UpdateDateColumn()` are the usual pair.

**An existing connection needs the entity registered on it.** `casbinAdapterOptions` also accepts `{ connection: dataSource }`, and typeorm-adapter assembles an `entities` list only on the other branch, the one where it opens the connection itself. Handed a `DataSource` it takes that one's entity list as it finds it, while still resolving the repository through your class — so declare the entity on that `DataSource` yourself, and let its `synchronize` or a migration create the table. Miss it and boot fails inside `loadPolicy()` with TypeORM's `EntityMetadataNotFoundError`, which names the entity but not the option that introduced it.

## Request-Aware Authorization (casbinDomainResolver and Decision Tracing)

By default, the built-in permission checker enforces against `payload.domain ?? DEFAULT_CASBIN_DOMAIN`. For per-resource multi-domain models (e.g. the target domain depends on GraphQL arguments), provide a `casbinDomainResolver`. The resolver receives the original Nest `ExecutionContext` (and the underlying request), returns one or more candidate domains, and the default checker allows the call if ANY returned domain passes ANY declared action (the same OR semantics as `AllowActions`). Returning an empty array denies immediately.
Expand Down Expand Up @@ -1012,14 +1043,14 @@ The address is stored as a `cidr`, so it carries the prefix length for its famil

### Which tables exist

| Table | Created by |
| ------------------------------- | ------------------------------- |
| `members` (+ your subclass) | package root, always |
| `member_login_logs` | package root, always |
| `member_password_histories` | package root, always |
| `member_oauth_records` | package root, always |
| `casbin_rule` | `casbinAdapterOptions` |
| `oidc_payloads`, `oidc_clients` | importing `/oidc-provider` only |
| Table | Created by |
| --------------------------------------------- | ------------------------------- |
| `members` (+ your subclass) | package root, always |
| `member_login_logs` | package root, always |
| `member_password_histories` | package root, always |
| `member_oauth_records` | package root, always |
| `casbin_rule` (or `casbinRuleEntity`'s table) | `casbinAdapterOptions` |
| `oidc_payloads`, `oidc_clients` | importing `/oidc-provider` only |

Column bounds follow one rule: **bounded when this package or a spec decides the value, unbounded when the application does.** So `oidc_clients.clientSecret`, `.name` and `.scope` are `text` — their length is the application's business, and with `secretCipher` the stored secret is whatever an external cipher produced. `clientId` stays `varchar(255)` because it is the primary key and a btree key cannot be unbounded, and `tokenEndpointAuthMethod` stays `varchar(64)` because the specs fix its values. Everything in `oidc_payloads` is generated by oidc-provider, so all of it is bounded.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { toTypeORMAdapterConfig } from '../src/constants/typeorm-adapter-config';
import type { MemberBaseModuleOptionsDTO } from '../src/typings/member-base-module-options.dto';

class CustomCasbinRule {
id!: number;
ptype!: string;
v0!: string;
}

/**
* typeorm-adapter reads the table name off the entity it is handed, so this is
* the only axis on which two applications sharing one database can keep their
* policies apart without also splitting the schema.
*/
describe('typeorm adapter config', () => {
describe('when casbinRuleEntity is not set', () => {
it.each([
['no options at all', undefined],
['an empty options object', {}],
['options that configure the adapter but not the entity', { casbinAdapterOptions: { type: 'postgres' } }],
])('should stay undefined for %s', (_label, options) => {
expect(toTypeORMAdapterConfig(options as MemberBaseModuleOptionsDTO | undefined)).toBeUndefined();
});
});

describe('when casbinRuleEntity is set', () => {
it('should hand the entity to typeorm-adapter as customCasbinRuleEntity', () => {
const options = { casbinRuleEntity: CustomCasbinRule } as unknown as MemberBaseModuleOptionsDTO;

expect(toTypeORMAdapterConfig(options)).toEqual({ customCasbinRuleEntity: CustomCasbinRule });
});

it('should pass the constructor itself, not a copy of it', () => {
const options = { casbinRuleEntity: CustomCasbinRule } as unknown as MemberBaseModuleOptionsDTO;

expect(toTypeORMAdapterConfig(options)?.customCasbinRuleEntity).toBe(CustomCasbinRule);
});
});
});
24 changes: 23 additions & 1 deletion packages/member-base-nestjs-module/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { MemberBaseModule } from '@rytass/member-base-nestjs-module';
// Casbin
enableGlobalGuard: true,
casbinAdapterOptions: { type: 'postgres', host: 'localhost', database: 'rytass' },
// casbinRuleEntity: BackendCasbinRule, // omit: CasbinRule from typeorm-adapter, table casbin_rule
}),
],
})
Expand All @@ -73,6 +74,27 @@ export class AppModule {}

`MemberBaseModule.forRootAsync({ useFactory, inject })` is also available.

### Casbin policy table

`casbinRuleEntity` names the table Casbin policies live in. Unset, typeorm-adapter's own `CasbinRule` is used and the table is `casbin_rule` — two applications on one database then share it, and since booting means "load the authoritative rules, clear the table, write them back", each one's startup briefly empties the table the other is enforcing against.

```typescript
import { Entity } from 'typeorm';
import { CasbinRule } from 'typeorm-adapter';

@Entity('backend_casbin_rule')
export class BackendCasbinRule extends CasbinRule {}

MemberBaseModule.forRoot({
casbinAdapterOptions: { type: 'postgres' /* ... */ },
casbinRuleEntity: BackendCasbinRule,
});
```

The entity is the row constructor and the repository target, not just a table name, so it must keep `CasbinRule`'s `ptype` and `v0`–`v5` columns — subclass it rather than redeclaring it. Extra columns of your own (`@CreateDateColumn()`, `@UpdateDateColumn()`) are supported.

With `casbinAdapterOptions: { connection: dataSource }` typeorm-adapter does not assemble its own `entities` list, so register the entity on that `DataSource` yourself; otherwise boot fails inside `loadPolicy()` with TypeORM's `EntityMetadataNotFoundError`. Setting the option on an application that already has policies starts it from an empty table — the rows are not migrated. Splitting the table separates the policy *cache*, not the permissions themselves.

### Cookie behaviour

`cookieMode: true` makes the module **read** the access token from a cookie on every request. It **writes** cookies only where it completes a login itself — the OAuth2 callback and, when mounted, the OIDC session bridge. `memberBaseService.login(...)` returns a token pair and writes nothing.
Expand Down Expand Up @@ -543,7 +565,7 @@ Note: `oidc-provider` is ESM-only and is loaded through an opaque dynamic import
| `member_login_logs` | package root, always |
| `member_password_histories` | package root, always |
| `member_oauth_records` | package root, always |
| `casbin_rule` | `casbinAdapterOptions` |
| `casbin_rule`, or `casbinRuleEntity`'s table | `casbinAdapterOptions` |
| `oidc_payloads`, `oidc_clients` | importing `/oidc-provider` only |

Entities: `BaseMemberEntity`, `MemberLoginLogEntity`, `MemberPasswordHistoryEntity`, `MemberOAuthRecordEntity`, `OidcPayloadEntity`, `OidcClientEntity`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { SUPER_ADMIN_ROLE } from './super-admin-role';
import { DEFAULT_CASBIN_DOMAIN } from './default-casbin-domain';
import type { PasswordHashOptions } from '../typings/password-hash-options';
import { getTypeORMAdapter } from './load-typeorm-adapter';
import { toTypeORMAdapterConfig } from './typeorm-adapter-config';
import type { ReflectableDecorator } from '@nestjs/core';
import type { OAuth2Provider } from '../typings/oauth2-provider.interface';
import type { AuthTokenPayloadBase } from '../typings/auth-token-payload';
Expand Down Expand Up @@ -127,7 +128,7 @@ export const OptionProviders = [
if (!options?.casbinAdapterOptions) return null;

const TypeORMAdapter = await getTypeORMAdapter();
const adapter = await TypeORMAdapter.newAdapter(options.casbinAdapterOptions);
const adapter = await TypeORMAdapter.newAdapter(options.casbinAdapterOptions, toTypeORMAdapterConfig(options));

const enforcer = await newEnforcer();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { MemberBaseModuleOptionsDTO } from '../typings/member-base-module-options.dto';
import type { CasbinRuleEntity } from '../typings/casbin-rule-entity';

/**
* The second argument of TypeORMAdapter.newAdapter.
*
* typeorm-adapter declares this shape as `TypeORMAdapterConfig` but does not
* re-export it from its entry point, so it is restated here rather than deep
* imported from `typeorm-adapter/lib/adapter`.
*/
export interface TypeORMAdapterConfig {
customCasbinRuleEntity?: CasbinRuleEntity;
}

/**
* Left undefined unless the application actually asked for a custom entity, so
* that newAdapter is called exactly as it was before this option existed.
*
* Built as a standalone function because the CASBIN_ENFORCER provider around it
* cannot be unit tested: it dynamic-imports typeorm-adapter and then opens a
* real connection. Same split as load-typeorm-adapter.ts.
*/
export const toTypeORMAdapterConfig = (options?: MemberBaseModuleOptionsDTO): TypeORMAdapterConfig | undefined =>
options?.casbinRuleEntity ? { customCasbinRuleEntity: options.casbinRuleEntity } : undefined;
1 change: 1 addition & 0 deletions packages/member-base-nestjs-module/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export type {
CasbinDomainResolver,
CasbinDomainResolverParams,
} from './typings/casbin-permission';
export type { CasbinRuleEntity } from './typings/casbin-rule-entity';

// Casbin
export * from './guards/casbin.guard';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { CasbinMongoRule, CasbinRule } from 'typeorm-adapter';

/**
* A replacement for typeorm-adapter's own `CasbinRule` entity.
*
* The constructor shape mirrors typeorm-adapter's internal `CasbinRuleConstructor`
* so a subclass of the exported `CasbinRule` (or `CasbinMongoRule`, on MongoDB)
* carrying `@Entity('another_table')` satisfies it directly.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CasbinRuleEntity = new (...args: any[]) => CasbinRule | CasbinMongoRule;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
CasbinPermissionCheckerParams,
CasbinPermissionCheckerResult,
} from './casbin-permission';
import type { CasbinRuleEntity } from './casbin-rule-entity';
import type { PasswordHashOptions } from './password-hash-options';
import type { RedirectAuthOptions } from './redirect-auth.options';

Expand Down Expand Up @@ -72,6 +73,18 @@ export interface MemberBaseModuleOptionsDTO<
superAdminRole?: string;
defaultCasbinDomain?: string;
casbinAdapterOptions?: TypeORMAdapterOptions;
/**
* Entity backing the Casbin policy table.
*
* default: typeorm-adapter's own `CasbinRule`, which is mapped to `casbin_rule`.
* Subclass it with `@Entity('another_table')` when two applications share one
* database and must not overwrite each other's policies — the table name is
* the only axis of separation that does not also require a separate schema.
*
* With `casbinAdapterOptions: { connection }` typeorm-adapter does not build
* its own entity list, so register this entity on that DataSource too.
*/
casbinRuleEntity?: CasbinRuleEntity;
casbinModelString?: string; // default: RBAC with domains
casbinPermissionDecorator?: ReflectableDecorator<[string, string][]>;
casbinPermissionChecker?: (params: CasbinPermissionCheckerParams<TokenPayload>) => CasbinPermissionCheckerResult;
Expand Down
Loading