From d065cbc623f15b650610f8c75b4c610f90412aa2 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 18 Sep 2023 16:31:23 +0800 Subject: [PATCH] refactor(schemas): sentinel first version --- .../next-1694854226-init-sentinel.ts | 79 +++++++++++++++++++ .../schemas/src/foundations/jsonb-types.ts | 22 ++---- packages/schemas/src/types/index.ts | 1 + packages/schemas/src/types/sentinel.ts | 32 ++++++++ .../schemas/tables/sentinel_activities.sql | 27 ++++--- 5 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 packages/schemas/alterations/next-1694854226-init-sentinel.ts create mode 100644 packages/schemas/src/types/sentinel.ts diff --git a/packages/schemas/alterations/next-1694854226-init-sentinel.ts b/packages/schemas/alterations/next-1694854226-init-sentinel.ts new file mode 100644 index 000000000..41a4a138d --- /dev/null +++ b/packages/schemas/alterations/next-1694854226-init-sentinel.ts @@ -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; diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index e947be8d5..927745050 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -339,14 +339,6 @@ export const domainStatusGuard = z.nativeEnum(DomainStatus); /* === Sentinel activities === */ -/** The subject (actor) type of a sentinel activity. */ -export enum SentinelActivitySubjectType { - User = 'User', - App = 'App', - Sentinel = 'Sentinel', -} -export const sentinelActivitySubjectTypeGuard = z.nativeEnum(SentinelActivitySubjectType); - /** The action target type of a sentinel activity. */ export enum SentinelActivityTargetType { User = 'User', @@ -357,19 +349,19 @@ export const sentinelActivityTargetTypeGuard = z.nativeEnum(SentinelActivityTarg /** The action type of a sentinel activity. */ export enum SentinelActivityAction { /** - * The subject tries to pass a verification for a target. + * The subject tries to pass a verification by inputting a password. * - * For example, a user (subject) who inputted a verification code or password for themselves + * For example, a user (subject) who inputted a password (action) to authenticate themselves * (target). */ - Verification = 'Verification', + Password = 'Password', /** - * The subject tries to block the target from passing a verification. + * The subject tries to pass a verification by inputting a verification code. * - * For example, the sentinel (subject) who blocked a user (target) from passing a verification - * for 10 minutes. + * For example, a user (subject) who inputted a verification code (action) to authenticate + * themselves (target). */ - BlockVerification = 'BlockVerification', + VerificationCode = 'VerificationCode', } export const sentinelActivityActionGuard = z.nativeEnum(SentinelActivityAction); diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index d047a7486..913012c23 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -18,3 +18,4 @@ export * from './theme.js'; export * from './cookie.js'; export * from './dashboard.js'; export * from './domain.js'; +export * from './sentinel.js'; diff --git a/packages/schemas/src/types/sentinel.ts b/packages/schemas/src/types/sentinel.ts new file mode 100644 index 000000000..b14ca8746 --- /dev/null +++ b/packages/schemas/src/types/sentinel.ts @@ -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; +} diff --git a/packages/schemas/tables/sentinel_activities.sql b/packages/schemas/tables/sentinel_activities.sql index f4fbab197..a0c7e7332 100644 --- a/packages/schemas/tables/sentinel_activities.sql +++ b/packages/schemas/tables/sentinel_activities.sql @@ -1,25 +1,32 @@ -create type sentinel_activity_result as enum ('Success', 'Failed'); +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 subject (actor) that performed the action. */ - subject_type varchar(32) /* @use SentinelActivitySubjectType */ not null, /** The target that the action was performed on. */ target_type varchar(32) /* @use SentinelActivityTargetType */ not null, - /** The target identifier. */ - target_id varchar(21) not null - references users (id) on update cascade on delete cascade, - /** The related log id if any. */ - log_id varchar(21) - references logs (id) on update cascade on delete cascade, + /** 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. */ - result sentinel_activity_result not null, + 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);