0
Fork 0
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:
Gao Sun 2023-09-22 14:03:22 +08:00 committed by GitHub
commit 202103b71f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 215 additions and 6 deletions

View file

@ -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;

View file

@ -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>;

View file

@ -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));

View file

@ -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' : ''
};`

View file

@ -1,5 +1,7 @@
export type Field = {
name: string;
/** The JSDoc comment for the field. */
comments?: string;
type?: string;
customType?: string;
tsType?: string;

View file

@ -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,

View file

@ -18,3 +18,4 @@ export * from './theme.js';
export * from './cookie.js';
export * from './dashboard.js';
export * from './domain.js';
export * from './sentinel.js';

View 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>;
}

View file

@ -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,

View 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);