mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
Merge pull request #4504 from logto-io/gao-init-sentinel
feat: init sentinel
This commit is contained in:
commit
202103b71f
10 changed files with 215 additions and 6 deletions
|
@ -0,0 +1,79 @@
|
|||
import { type CommonQueryMethods, sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const getDatabaseName = async (pool: CommonQueryMethods) => {
|
||||
const { currentDatabase } = await pool.one<{ currentDatabase: string }>(sql`
|
||||
select current_database();
|
||||
`);
|
||||
|
||||
return currentDatabase.replaceAll('-', '_');
|
||||
};
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
const database = await getDatabaseName(pool);
|
||||
const baseRoleId = sql.identifier([`logto_tenant_${database}`]);
|
||||
|
||||
await pool.query(sql`
|
||||
create type sentinel_action_result as enum ('Success', 'Failed');
|
||||
|
||||
create type sentinel_decision as enum ('Undecided', 'Allowed', 'Blocked', 'Challenge');
|
||||
|
||||
create table sentinel_activities (
|
||||
tenant_id varchar(21) not null
|
||||
references tenants (id) on update cascade on delete cascade,
|
||||
id varchar(21) not null,
|
||||
/** The target that the action was performed on. */
|
||||
target_type varchar(32) /* @use SentinelActivityTargetType */ not null,
|
||||
/** The target hashed identifier. */
|
||||
target_hash varchar(64) not null,
|
||||
/** The action name that was performed. */
|
||||
action varchar(64) /* @use SentinelActivityAction */ not null,
|
||||
/** If the action was successful or not. */
|
||||
action_result sentinel_action_result not null,
|
||||
/** Additional payload data if any. */
|
||||
payload jsonb /* @use SentinelActivityPayload */ not null,
|
||||
/** The sentinel decision for the action. */
|
||||
decision sentinel_decision not null,
|
||||
/** The expiry date of the decision. */
|
||||
decision_expires_at timestamptz not null default(now()),
|
||||
/** The time the activity was created. */
|
||||
created_at timestamptz not null default(now()),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create index sentinel_activities__id
|
||||
on sentinel_activities (tenant_id, id);
|
||||
|
||||
create index sentinel_activities__target_type_target_hash_action_action_result_decision
|
||||
on sentinel_activities (tenant_id, target_type, target_hash, action, action_result, decision);
|
||||
|
||||
create trigger set_tenant_id before insert on sentinel_activities
|
||||
for each row execute procedure set_tenant_id();
|
||||
|
||||
alter table sentinel_activities enable row level security;
|
||||
|
||||
create policy sentinel_activities_tenant_id on sentinel_activities
|
||||
as restrictive
|
||||
using (tenant_id = (select id from tenants where db_user = current_user));
|
||||
|
||||
create policy sentinel_activities_modification on sentinel_activities
|
||||
using (true);
|
||||
|
||||
grant select, insert, update, delete on sentinel_activities to ${baseRoleId};
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
drop policy sentinel_activities_tenant_id on sentinel_activities;
|
||||
drop policy sentinel_activities_modification on sentinel_activities;
|
||||
|
||||
drop table sentinel_activities;
|
||||
drop type sentinel_action_result;
|
||||
drop type sentinel_decision;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -293,6 +293,8 @@ export const hookConfigGuard = z.object({
|
|||
|
||||
export type HookConfig = z.infer<typeof hookConfigGuard>;
|
||||
|
||||
/* === Custom domains and Cloudflare === */
|
||||
|
||||
export const domainDnsRecordGuard = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
|
@ -334,3 +336,36 @@ export enum DomainStatus {
|
|||
}
|
||||
|
||||
export const domainStatusGuard = z.nativeEnum(DomainStatus);
|
||||
|
||||
/* === Sentinel activities === */
|
||||
|
||||
/** The action target type of a sentinel activity. */
|
||||
export enum SentinelActivityTargetType {
|
||||
User = 'User',
|
||||
App = 'App',
|
||||
}
|
||||
export const sentinelActivityTargetTypeGuard = z.nativeEnum(SentinelActivityTargetType);
|
||||
|
||||
/** The action type of a sentinel activity. */
|
||||
export enum SentinelActivityAction {
|
||||
/**
|
||||
* The subject tries to pass a verification by inputting a password.
|
||||
*
|
||||
* For example, a user (subject) who inputted a password (action) to authenticate themselves
|
||||
* (target).
|
||||
*/
|
||||
Password = 'Password',
|
||||
/**
|
||||
* The subject tries to pass a verification by inputting a verification code.
|
||||
*
|
||||
* For example, a user (subject) who inputted a verification code (action) to authenticate
|
||||
* themselves (target).
|
||||
*/
|
||||
VerificationCode = 'VerificationCode',
|
||||
}
|
||||
export const sentinelActivityActionGuard = z.nativeEnum(SentinelActivityAction);
|
||||
|
||||
export type SentinelActivityPayload = Record<string, unknown>;
|
||||
export const sentinelActivityPayloadGuard = z.record(
|
||||
z.unknown()
|
||||
) satisfies z.ZodType<SentinelActivityPayload>;
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
parseType,
|
||||
removeUnrecognizedComments,
|
||||
splitTableFieldDefinitions,
|
||||
stripLeadingJsDocComments as stripComments,
|
||||
} from './utils.js';
|
||||
|
||||
const directory = 'tables';
|
||||
|
@ -61,7 +62,10 @@ const generate = async () => {
|
|||
.map((value) => normalizeWhitespaces(value))
|
||||
.filter((value) =>
|
||||
constrainedKeywords.every(
|
||||
(constraint) => !value.toLowerCase().startsWith(constraint + ' ')
|
||||
(constraint) =>
|
||||
!stripComments(value)
|
||||
.toLowerCase()
|
||||
.startsWith(constraint + ' ')
|
||||
)
|
||||
)
|
||||
.map<Field>((value) => parseType(value));
|
||||
|
|
|
@ -16,7 +16,8 @@ export const generateSchema = ({ name, fields }: TableWithType) => {
|
|||
return [
|
||||
`export type ${databaseEntryType} = {`,
|
||||
...fields.map(
|
||||
({ name, type, isArray, nullable, hasDefaultValue }) =>
|
||||
({ name, comments, type, isArray, nullable, hasDefaultValue }) =>
|
||||
conditionalString(comments && ` /**${comments}*/\n`) +
|
||||
` ${camelcase(name)}${conditionalString(
|
||||
(nullable || hasDefaultValue || name === tenantId) && '?'
|
||||
)}: ${type}${conditionalString(isArray && '[]')}${conditionalString(
|
||||
|
@ -27,7 +28,8 @@ export const generateSchema = ({ name, fields }: TableWithType) => {
|
|||
'',
|
||||
`export type ${modelName} = {`,
|
||||
...fields.map(
|
||||
({ name, type, isArray, nullable, hasDefaultValue }) =>
|
||||
({ name, comments, type, isArray, nullable, hasDefaultValue }) =>
|
||||
conditionalString(comments && ` /**${comments}*/\n`) +
|
||||
` ${camelcase(name)}: ${type}${conditionalString(isArray && '[]')}${
|
||||
nullable && !hasDefaultValue ? ' | null' : ''
|
||||
};`
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
export type Field = {
|
||||
name: string;
|
||||
/** The JSDoc comment for the field. */
|
||||
comments?: string;
|
||||
type?: string;
|
||||
customType?: string;
|
||||
tsType?: string;
|
||||
|
|
|
@ -6,6 +6,17 @@ import type { Field } from './types.js';
|
|||
export const normalizeWhitespaces = (string: string): string =>
|
||||
string.replaceAll(/\s+/g, ' ').trim();
|
||||
|
||||
// eslint-disable-next-line unicorn/prevent-abbreviations -- JSDoc is a term
|
||||
const leadingJsDocRegex = /^\s*\/\*\*([^*]*?)\*\//;
|
||||
|
||||
// eslint-disable-next-line unicorn/prevent-abbreviations -- JSDoc is a term
|
||||
export const stripLeadingJsDocComments = (string: string): string =>
|
||||
string.replace(leadingJsDocRegex, '').trim();
|
||||
|
||||
// eslint-disable-next-line unicorn/prevent-abbreviations -- JSDoc is a term
|
||||
const getLeadingJsDocComments = (string: string): Optional<string> =>
|
||||
leadingJsDocRegex.exec(string)?.[1];
|
||||
|
||||
// Remove all comments not start with @
|
||||
export const removeUnrecognizedComments = (string: string): string =>
|
||||
string.replaceAll(/\/\*(?!\s@)[^*]+\*\//g, '');
|
||||
|
@ -73,7 +84,12 @@ export const splitTableFieldDefinitions = (value: string) =>
|
|||
({ result, count: previousCount }, current) => {
|
||||
const count = previousCount + getCountDelta(current);
|
||||
|
||||
if (count === 0 && current === ',') {
|
||||
if (
|
||||
count === 0 &&
|
||||
current === ',' &&
|
||||
// Ignore commas in JSDoc comments
|
||||
!stripLeadingJsDocComments(result.at(-1) ?? '').includes('/**')
|
||||
) {
|
||||
return {
|
||||
result: [...result, ''],
|
||||
count,
|
||||
|
@ -169,9 +185,12 @@ const parseStringMaxLength = (rawType: string) => {
|
|||
};
|
||||
|
||||
export const parseType = (tableFieldDefinition: string): Field => {
|
||||
const [nameRaw, typeRaw, ...rest] = tableFieldDefinition.split(' ');
|
||||
const normalized = stripLeadingJsDocComments(tableFieldDefinition);
|
||||
const comments = getLeadingJsDocComments(tableFieldDefinition);
|
||||
|
||||
assert(nameRaw && typeRaw, new Error('Missing field name or type: ' + tableFieldDefinition));
|
||||
const [nameRaw, typeRaw, ...rest] = normalized.split(' ');
|
||||
|
||||
assert(nameRaw && typeRaw, new Error('Missing field name or type: ' + normalized));
|
||||
|
||||
const name = nameRaw.toLowerCase();
|
||||
const type = typeRaw.toLowerCase();
|
||||
|
@ -198,6 +217,7 @@ export const parseType = (tableFieldDefinition: string): Field => {
|
|||
|
||||
return {
|
||||
name,
|
||||
comments,
|
||||
type: primitiveType,
|
||||
isString,
|
||||
isArray,
|
||||
|
|
|
@ -18,3 +18,4 @@ export * from './theme.js';
|
|||
export * from './cookie.js';
|
||||
export * from './dashboard.js';
|
||||
export * from './domain.js';
|
||||
export * from './sentinel.js';
|
||||
|
|
32
packages/schemas/src/types/sentinel.ts
Normal file
32
packages/schemas/src/types/sentinel.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { type SentinelDecision, type SentinelActivity } from '../db-entries/index.js';
|
||||
|
||||
/** The activity payload to be sent to the sentinel. */
|
||||
export type ActivityReport = Pick<
|
||||
SentinelActivity,
|
||||
'targetType' | 'targetHash' | 'action' | 'actionResult' | 'payload'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The sentinel class interface.
|
||||
*
|
||||
* Sentinels are responsible for accepting activity reports and making decisions based on them.
|
||||
*
|
||||
* For example, for a user sign-in activity, the sentinel might decide to:
|
||||
*
|
||||
* - Accept since the user uses the same device and location as usual;
|
||||
* - Require a MFA code since the user uses a new device or location;
|
||||
* - Block the user since the user tried to sign-in too many times with an incorrect password.
|
||||
*
|
||||
* The implementation should be privacy-aware and not store any personal identifiable information.
|
||||
*/
|
||||
export abstract class Sentinel {
|
||||
/**
|
||||
* Report an activity to the sentinel. The sentinel should make a decision based on the activity
|
||||
* (also the history, if needed) and return the result.
|
||||
*
|
||||
* @param activity The activity data to be reported.
|
||||
* @returns A Promise that resolves to the sentinel decision.
|
||||
* @see {@link SentinelDecision}
|
||||
*/
|
||||
abstract reportActivity(activity: ActivityReport): Promise<SentinelDecision>;
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
/* init_order = 2 */
|
||||
|
||||
create table logs (
|
||||
tenant_id varchar(21) not null
|
||||
references tenants (id) on update cascade on delete cascade,
|
||||
|
|
32
packages/schemas/tables/sentinel_activities.sql
Normal file
32
packages/schemas/tables/sentinel_activities.sql
Normal file
|
@ -0,0 +1,32 @@
|
|||
create type sentinel_action_result as enum ('Success', 'Failed');
|
||||
|
||||
create type sentinel_decision as enum ('Undecided', 'Allowed', 'Blocked', 'Challenge');
|
||||
|
||||
create table sentinel_activities (
|
||||
tenant_id varchar(21) not null
|
||||
references tenants (id) on update cascade on delete cascade,
|
||||
id varchar(21) not null,
|
||||
/** The target that the action was performed on. */
|
||||
target_type varchar(32) /* @use SentinelActivityTargetType */ not null,
|
||||
/** The target hashed identifier. */
|
||||
target_hash varchar(64) not null,
|
||||
/** The action name that was performed. */
|
||||
action varchar(64) /* @use SentinelActivityAction */ not null,
|
||||
/** If the action was successful or not. */
|
||||
action_result sentinel_action_result not null,
|
||||
/** Additional payload data if any. */
|
||||
payload jsonb /* @use SentinelActivityPayload */ not null,
|
||||
/** The sentinel decision for the action. */
|
||||
decision sentinel_decision not null,
|
||||
/** The expiry date of the decision. For instant decisions, this is the date the activity was created. */
|
||||
decision_expires_at timestamptz not null default(now()),
|
||||
/** The time the activity was created. */
|
||||
created_at timestamptz not null default(now()),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create index sentinel_activities__id
|
||||
on sentinel_activities (tenant_id, id);
|
||||
|
||||
create index sentinel_activities__target_type_target_hash_action_action_result_decision
|
||||
on sentinel_activities (tenant_id, target_type, target_hash, action, action_result, decision);
|
Loading…
Reference in a new issue