Skip to content

Commit e73adf1

Browse files
authored
feat(core): read sentinel policy settings (#7255)
* feat(core): read sentinel policy settings read custom sentinel policy settings from SIE * fix(test): fix integration tests fix integration tests
1 parent 0acec77 commit e73adf1

File tree

3 files changed

+156
-10
lines changed

3 files changed

+156
-10
lines changed

packages/core/src/sentinel/basic-sentinel.test.ts

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ import {
66
SentinelDecision,
77
} from '@logto/schemas';
88
import { addMinutes } from 'date-fns';
9+
import Sinon from 'sinon';
910

1011
import { createMockCommonQueryMethods, expectSqlString } from '#src/test-utils/query.js';
1112

13+
import { mockSignInExperience } from '../__mocks__/sign-in-experience.js';
14+
import { EnvSet } from '../env-set/index.js';
15+
import { MockQueries } from '../test-utils/tenant.js';
16+
1217
import BasicSentinel from './basic-sentinel.js';
1318

1419
const { jest } = import.meta;
@@ -25,10 +30,27 @@ class TestSentinel extends BasicSentinel {
2530
override decide = super.decide;
2631
}
2732

33+
const findDefaultSignInExperienceMock = jest.fn();
34+
2835
const methods = createMockCommonQueryMethods();
29-
const sentinel = new TestSentinel(methods);
36+
const sentinel = new TestSentinel(
37+
methods,
38+
new MockQueries({
39+
signInExperiences: {
40+
findDefaultSignInExperience: findDefaultSignInExperienceMock,
41+
},
42+
})
43+
);
3044
const mockedTime = new Date('2021-01-01T00:00:00.000Z').valueOf();
31-
const mockedBlockedTime = addMinutes(mockedTime, 10).valueOf();
45+
const mockedDefaultBlockedTime = addMinutes(mockedTime, 10).valueOf();
46+
const customSentinelPolicy = {
47+
maxAttempts: 7,
48+
lockoutDuration: 15,
49+
};
50+
const mockedCustomBlockedTime = addMinutes(
51+
mockedTime,
52+
customSentinelPolicy.lockoutDuration
53+
).valueOf();
3254

3355
beforeAll(() => {
3456
jest.useFakeTimers().setSystemTime(mockedTime);
@@ -39,6 +61,10 @@ afterEach(() => {
3961
});
4062

4163
describe('BasicSentinel -> reportActivity()', () => {
64+
beforeEach(() => {
65+
findDefaultSignInExperienceMock.mockResolvedValue(mockSignInExperience);
66+
});
67+
4268
it('should insert an activity', async () => {
4369
methods.maybeOne.mockResolvedValueOnce(null);
4470
methods.oneFirst.mockResolvedValueOnce(0);
@@ -55,11 +81,11 @@ describe('BasicSentinel -> reportActivity()', () => {
5581

5682
it('should insert a blocked activity', async () => {
5783
// Mock the query method to return a blocked activity
58-
methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: mockedBlockedTime });
84+
methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: mockedDefaultBlockedTime });
5985

6086
const activity = createMockActivityReport();
6187
const decision = await sentinel.reportActivity(activity);
62-
expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]);
88+
expect(decision).toEqual([SentinelDecision.Blocked, mockedDefaultBlockedTime]);
6389
expect(methods.query).toHaveBeenCalledTimes(1);
6490
expect(methods.query).toHaveBeenCalledWith(
6591
expectSqlString('insert into "sentinel_activities"')
@@ -68,6 +94,10 @@ describe('BasicSentinel -> reportActivity()', () => {
6894
});
6995

7096
describe('BasicSentinel -> decide()', () => {
97+
beforeEach(() => {
98+
findDefaultSignInExperienceMock.mockResolvedValue(mockSignInExperience);
99+
});
100+
71101
it('should return existing blocked time if the activity is blocked', async () => {
72102
const existingBlockedTime = addMinutes(mockedTime, 5).valueOf();
73103
methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: existingBlockedTime });
@@ -92,7 +122,7 @@ describe('BasicSentinel -> decide()', () => {
92122

93123
const activity = createMockActivityReport();
94124
const decision = await sentinel.decide(activity);
95-
expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]);
125+
expect(decision).toEqual([SentinelDecision.Blocked, mockedDefaultBlockedTime]);
96126
});
97127

98128
it('should return blocked if the activity is not blocked and there are 4 failed attempts and the current activity is failed', async () => {
@@ -103,6 +133,83 @@ describe('BasicSentinel -> decide()', () => {
103133
// eslint-disable-next-line @silverhand/fp/no-mutation
104134
activity.actionResult = SentinelActionResult.Failed;
105135
const decision = await sentinel.decide(activity);
106-
expect(decision).toEqual([SentinelDecision.Blocked, mockedBlockedTime]);
136+
expect(decision).toEqual([SentinelDecision.Blocked, mockedDefaultBlockedTime]);
137+
});
138+
});
139+
140+
describe('BasicSentinel with custom policy', () => {
141+
// eslint-disable-next-line @silverhand/fp/no-let
142+
let stub: Sinon.SinonStub;
143+
144+
// TODO: Remove this when the sentinel policy is fully integrated into the system.
145+
beforeAll(() => {
146+
// eslint-disable-next-line @silverhand/fp/no-mutation
147+
stub = Sinon.stub(EnvSet, 'values').value({
148+
...EnvSet.values,
149+
isProduction: true,
150+
isDevFeaturesEnabled: true,
151+
});
152+
});
153+
154+
afterAll(() => {
155+
stub.restore();
156+
});
157+
158+
beforeEach(() => {
159+
findDefaultSignInExperienceMock.mockResolvedValue({
160+
...mockSignInExperience,
161+
sentinelPolicy: customSentinelPolicy,
162+
});
163+
});
164+
165+
it('should insert a blocked activity', async () => {
166+
// Mock the query method to return a blocked activity
167+
methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: mockedCustomBlockedTime });
168+
169+
const activity = createMockActivityReport();
170+
const decision = await sentinel.reportActivity(activity);
171+
expect(decision).toEqual([SentinelDecision.Blocked, mockedCustomBlockedTime]);
172+
expect(methods.query).toHaveBeenCalledTimes(1);
173+
expect(methods.query).toHaveBeenCalledWith(
174+
expectSqlString('insert into "sentinel_activities"')
175+
);
176+
});
177+
178+
it('should return existing blocked time if the activity is blocked', async () => {
179+
const existingBlockedTime = addMinutes(mockedTime, 5).valueOf();
180+
methods.maybeOne.mockResolvedValueOnce({ decisionExpiresAt: existingBlockedTime });
181+
182+
const activity = createMockActivityReport();
183+
const decision = await sentinel.decide(activity);
184+
expect(decision).toEqual([SentinelDecision.Blocked, existingBlockedTime]);
185+
});
186+
187+
it('should return allowed if the activity is not blocked and there are less than 7 failed attempts', async () => {
188+
methods.maybeOne.mockResolvedValueOnce(null);
189+
methods.oneFirst.mockResolvedValueOnce(6);
190+
191+
const activity = createMockActivityReport();
192+
const decision = await sentinel.decide(activity);
193+
expect(decision).toEqual([SentinelDecision.Allowed, mockedTime]);
194+
});
195+
196+
it('should return blocked if the activity is not blocked and there are 7 failed attempts', async () => {
197+
methods.maybeOne.mockResolvedValueOnce(null);
198+
methods.oneFirst.mockResolvedValueOnce(7);
199+
200+
const activity = createMockActivityReport();
201+
const decision = await sentinel.decide(activity);
202+
expect(decision).toEqual([SentinelDecision.Blocked, mockedCustomBlockedTime]);
203+
});
204+
205+
it('should return blocked if the activity is not blocked and there are 4 failed attempts and the current activity is failed', async () => {
206+
methods.maybeOne.mockResolvedValueOnce(null);
207+
methods.oneFirst.mockResolvedValueOnce(6);
208+
209+
const activity = createMockActivityReport();
210+
// eslint-disable-next-line @silverhand/fp/no-mutation
211+
activity.actionResult = SentinelActionResult.Failed;
212+
const decision = await sentinel.decide(activity);
213+
expect(decision).toEqual([SentinelDecision.Blocked, mockedCustomBlockedTime]);
107214
});
108215
});

packages/core/src/sentinel/basic-sentinel.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ import { sql, type CommonQueryMethods } from '@silverhand/slonik';
1414
import { addMinutes } from 'date-fns';
1515

1616
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
17+
import type Queries from '#src/tenants/Queries.js';
1718
import { convertToIdentifiers } from '#src/utils/sql.js';
1819

20+
import { EnvSet } from '../env-set/index.js';
21+
1922
const { fields, table } = convertToIdentifiers(SentinelActivities);
2023

2124
/**
@@ -31,6 +34,17 @@ export default class BasicSentinel extends Sentinel {
3134
SentinelActivityAction.OneTimeToken,
3235
] as const);
3336

37+
/**
38+
* The default policy for this sentinel.
39+
*
40+
* - `maxAttempts`: 5
41+
* - `lockoutDuration`: 10 minutes
42+
*/
43+
static defaultPolicy = Object.freeze({
44+
maxAttempts: 5,
45+
lockoutDuration: 10,
46+
});
47+
3448
/** The array of all supported actions in SQL format. */
3549
static supportedActionArray = sql.array(BasicSentinel.supportedActions, 'varchar');
3650

@@ -55,8 +69,12 @@ export default class BasicSentinel extends Sentinel {
5569
* designed to be used as an isolated module that can be separated from the core business logic.
5670
*
5771
* @param pool A database pool with methods {@link CommonQueryMethods}.
72+
* @param {Queries} queries Tenant-level queries.
5873
*/
59-
constructor(protected readonly pool: CommonQueryMethods) {
74+
constructor(
75+
protected readonly pool: CommonQueryMethods,
76+
protected readonly queries: Queries
77+
) {
6078
super();
6179
}
6280

@@ -111,6 +129,24 @@ export default class BasicSentinel extends Sentinel {
111129
return blocked && [SentinelDecision.Blocked, blocked.decisionExpiresAt];
112130
}
113131

132+
protected async getSentinelPolicy() {
133+
// TODO: remove this check when the sentinel policy is fully integrated into the system.
134+
if (!EnvSet.values.isDevFeaturesEnabled) {
135+
return BasicSentinel.defaultPolicy;
136+
}
137+
138+
const {
139+
signInExperiences: { findDefaultSignInExperience },
140+
} = this.queries;
141+
142+
const { sentinelPolicy } = await findDefaultSignInExperience();
143+
144+
return {
145+
...BasicSentinel.defaultPolicy,
146+
...sentinelPolicy,
147+
};
148+
}
149+
114150
protected async decide(
115151
query: Pick<SentinelActivity, 'targetType' | 'targetHash' | 'actionResult'>
116152
): Promise<SentinelDecisionTuple> {
@@ -129,10 +165,13 @@ export default class BasicSentinel extends Sentinel {
129165
and ${fields.decision} != ${SentinelDecision.Blocked}
130166
and ${fields.createdAt} > now() - interval '1 hour'
131167
`);
168+
const { maxAttempts, lockoutDuration } = await this.getSentinelPolicy();
169+
132170
const now = new Date();
133171

134-
return failedAttempts + (query.actionResult === SentinelActionResult.Failed ? 1 : 0) >= 5
135-
? [SentinelDecision.Blocked, addMinutes(now, 10).valueOf()]
172+
return failedAttempts + (query.actionResult === SentinelActionResult.Failed ? 1 : 0) >=
173+
maxAttempts
174+
? [SentinelDecision.Blocked, addMinutes(now, lockoutDuration).valueOf()]
136175
: [SentinelDecision.Allowed, now.valueOf()];
137176
}
138177
}

packages/core/src/tenants/Tenant.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export default class Tenant implements TenantContext {
100100
logtoConfigs,
101101
subscription
102102
),
103-
public readonly sentinel = new BasicSentinel(envSet.pool)
103+
public readonly sentinel = new BasicSentinel(envSet.pool, queries)
104104
) {
105105
const isAdminTenant = id === adminTenantId;
106106
const mountedApps = [

0 commit comments

Comments
 (0)