@ftisindia/create-app 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.env.example +6 -0
- package/template/README.md +11 -1
- package/template/_package.json +0 -2
- package/template/docs/API_REFERENCE.md +13 -0
- package/template/docs/OAUTH.md +7 -3
- package/template/scripts/gen-module.mjs +2 -0
- package/template/src/app.module.ts +16 -22
- package/template/src/common/dto/error-response.dto.ts +3 -3
- package/template/src/common/dto/membership-response.dto.ts +26 -14
- package/template/src/common/dto/mutation-response.dto.ts +1 -1
- package/template/src/common/dto/pagination-query.dto.ts +37 -0
- package/template/src/common/dto/role-summary.dto.ts +5 -5
- package/template/src/common/dto/user-summary.dto.ts +6 -6
- package/template/src/common/filters/http-exception.filter.ts +9 -19
- package/template/src/common/swagger/api-error-responses.ts +12 -12
- package/template/src/config/app.config.ts +8 -3
- package/template/src/config/auth.config.ts +3 -3
- package/template/src/config/database.config.ts +3 -3
- package/template/src/config/env.validation.ts +78 -40
- package/template/src/config/index.ts +5 -5
- package/template/src/config/rbac.config.ts +3 -3
- package/template/src/database/prisma/prisma-transaction.ts +1 -1
- package/template/src/database/prisma/prisma.module.ts +2 -2
- package/template/src/database/prisma/prisma.service.ts +3 -6
- package/template/src/main.ts +24 -11
- package/template/src/modules/access-control/access-control.module.ts +11 -10
- package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
- package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
- package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
- package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
- package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
- package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +31 -0
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
- package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
- package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
- package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
- package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
- package/template/src/modules/access-control/types/permission-key.ts +19 -19
- package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
- package/template/src/modules/audit/application/services/audit.service.ts +7 -7
- package/template/src/modules/audit/audit.module.ts +4 -4
- package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
- package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
- package/template/src/modules/auth/application/services/auth.service.ts +147 -110
- package/template/src/modules/auth/application/services/password.service.ts +2 -2
- package/template/src/modules/auth/application/services/token.service.ts +20 -21
- package/template/src/modules/auth/auth.module.ts +20 -47
- package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
- package/template/src/modules/auth/dto/login.dto.ts +4 -4
- package/template/src/modules/auth/dto/logout.dto.ts +1 -1
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
- package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
- package/template/src/modules/auth/dto/signup.dto.ts +5 -11
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
- package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
- package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
- package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
- package/template/src/modules/health/dto/health-response.dto.ts +5 -5
- package/template/src/modules/health/health.module.ts +2 -2
- package/template/src/modules/health/presentation/health.controller.ts +13 -13
- package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
- package/template/src/modules/invitations/invitations.module.ts +5 -5
- package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
- package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
- package/template/src/modules/memberships/memberships.module.ts +4 -4
- package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
- package/template/src/modules/organisations/application/services/organisations.service.ts +87 -23
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +65 -14
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
- package/template/src/modules/organisations/organisations.module.ts +5 -5
- package/template/src/modules/organisations/presentation/organisations.controller.ts +31 -18
- package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
- package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
- package/template/src/modules/request-context/request-context.module.ts +7 -7
- package/template/src/modules/request-context/types/request-context.ts +2 -2
- package/template/src/modules/sample/application/services/sample.service.ts +10 -8
- package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
- package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
- package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
- package/template/src/modules/sample/sample.module.ts +4 -4
- package/template/src/modules/settings/application/services/settings.service.ts +15 -27
- package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
- package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
- package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
- package/template/src/modules/settings/settings.module.ts +5 -5
- package/template/src/modules/settings/types/setting-definitions.ts +49 -33
- package/template/test/auth-refresh.spec.ts +90 -0
- package/template/test/frontend-bootstrap.spec.ts +181 -0
- package/template/test/role-permission-policy.spec.ts +94 -0
- package/template/test/route-registry.validator.spec.ts +12 -0
- package/template/test/security.e2e-spec.ts +134 -2
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { AuthService } from '../src/modules/auth/application/services/auth.service';
|
|
3
|
+
|
|
4
|
+
describe('AuthService.refresh', () => {
|
|
5
|
+
it('commits refresh-token family revocation when a concurrent replay loses the claim', async () => {
|
|
6
|
+
const existingToken = {
|
|
7
|
+
id: 'old-token-id',
|
|
8
|
+
userId: 'user-id',
|
|
9
|
+
revokedAt: null,
|
|
10
|
+
replacedByTokenId: null,
|
|
11
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
12
|
+
user: {
|
|
13
|
+
id: 'user-id',
|
|
14
|
+
email: 'owner@example.com',
|
|
15
|
+
mobile: null,
|
|
16
|
+
displayName: null,
|
|
17
|
+
isActive: true,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const tx = {
|
|
21
|
+
refreshToken: {
|
|
22
|
+
updateMany: jest
|
|
23
|
+
.fn()
|
|
24
|
+
.mockResolvedValueOnce({ count: 0 })
|
|
25
|
+
.mockResolvedValueOnce({ count: 2 }),
|
|
26
|
+
findUnique: jest
|
|
27
|
+
.fn()
|
|
28
|
+
.mockResolvedValueOnce({ replacedByTokenId: 'next-token-id' })
|
|
29
|
+
.mockResolvedValueOnce({ replacedByTokenId: 'next-token-id' })
|
|
30
|
+
.mockResolvedValueOnce({ replacedByTokenId: null }),
|
|
31
|
+
},
|
|
32
|
+
auditLog: {
|
|
33
|
+
create: jest.fn().mockResolvedValue({}),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const prisma = {
|
|
37
|
+
refreshToken: {
|
|
38
|
+
findUnique: jest.fn().mockResolvedValue(existingToken),
|
|
39
|
+
},
|
|
40
|
+
$transaction: jest.fn((callback: (txClient: typeof tx) => unknown) =>
|
|
41
|
+
Promise.resolve(callback(tx)),
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
const tokenService = {
|
|
45
|
+
hashRefreshToken: jest.fn().mockReturnValue('refresh-token-hash'),
|
|
46
|
+
createRefreshToken: jest.fn(),
|
|
47
|
+
signAccessToken: jest.fn(),
|
|
48
|
+
getAccessTokenTtlSeconds: jest.fn(),
|
|
49
|
+
};
|
|
50
|
+
const requestContext = {
|
|
51
|
+
getIpAddress: jest.fn(),
|
|
52
|
+
getUserAgent: jest.fn(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const service = new AuthService(
|
|
56
|
+
{} as never,
|
|
57
|
+
{} as never,
|
|
58
|
+
prisma as never,
|
|
59
|
+
requestContext as never,
|
|
60
|
+
tokenService as never,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await expect(
|
|
64
|
+
service.refresh({ refreshToken: 'stale-refresh-token-value' }),
|
|
65
|
+
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
66
|
+
|
|
67
|
+
expect(tx.refreshToken.updateMany).toHaveBeenNthCalledWith(
|
|
68
|
+
2,
|
|
69
|
+
expect.objectContaining({
|
|
70
|
+
where: {
|
|
71
|
+
id: {
|
|
72
|
+
in: ['old-token-id', 'next-token-id'],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
data: {
|
|
76
|
+
revokedAt: expect.any(Date),
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
expect(tx.auditLog.create).toHaveBeenCalledWith(
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
data: expect.objectContaining({
|
|
83
|
+
action: 'auth.refresh.reuse_detected',
|
|
84
|
+
targetId: 'old-token-id',
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
expect(tokenService.createRefreshToken).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { MembershipStatus, OrganisationStatus } from '@prisma/client';
|
|
2
|
+
import { validate, parseCorsOrigins } from '../src/config/env.validation';
|
|
3
|
+
import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
|
|
4
|
+
import { OrganisationsService } from '../src/modules/organisations/application/services/organisations.service';
|
|
5
|
+
|
|
6
|
+
describe('frontend bootstrap endpoints', () => {
|
|
7
|
+
it('returns the current effective access-control context without internal cache fields', async () => {
|
|
8
|
+
const controller = new CurrentAccessControlController({
|
|
9
|
+
getContext: jest.fn().mockResolvedValue({
|
|
10
|
+
cacheKey: 'rbac:user-1:org-1',
|
|
11
|
+
userId: 'user-1',
|
|
12
|
+
orgId: 'org-1',
|
|
13
|
+
membershipId: 'membership-1',
|
|
14
|
+
membershipStatus: MembershipStatus.ACTIVE,
|
|
15
|
+
roleId: 'role-1',
|
|
16
|
+
isOwner: false,
|
|
17
|
+
isBillingContact: true,
|
|
18
|
+
permissionKeys: ['organisations.read'],
|
|
19
|
+
}),
|
|
20
|
+
} as never);
|
|
21
|
+
|
|
22
|
+
await expect(
|
|
23
|
+
controller.me(
|
|
24
|
+
{
|
|
25
|
+
id: 'user-1',
|
|
26
|
+
email: 'user@example.com',
|
|
27
|
+
mobile: null,
|
|
28
|
+
displayName: 'User',
|
|
29
|
+
isActive: true,
|
|
30
|
+
},
|
|
31
|
+
'org-1',
|
|
32
|
+
),
|
|
33
|
+
).resolves.toEqual({
|
|
34
|
+
orgId: 'org-1',
|
|
35
|
+
membershipId: 'membership-1',
|
|
36
|
+
roleId: 'role-1',
|
|
37
|
+
isOwner: false,
|
|
38
|
+
isBillingContact: true,
|
|
39
|
+
permissionKeys: ['organisations.read'],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('queries only active current-user memberships in active organisations', async () => {
|
|
44
|
+
const prisma = {
|
|
45
|
+
membership: {
|
|
46
|
+
findMany: jest.fn().mockResolvedValue([
|
|
47
|
+
{
|
|
48
|
+
id: 'membership-1',
|
|
49
|
+
roleId: 'role-1',
|
|
50
|
+
isOwner: true,
|
|
51
|
+
isBillingContact: true,
|
|
52
|
+
organisation: {
|
|
53
|
+
id: 'org-1',
|
|
54
|
+
name: 'Acme Operations',
|
|
55
|
+
slug: 'acme',
|
|
56
|
+
status: OrganisationStatus.ACTIVE,
|
|
57
|
+
},
|
|
58
|
+
role: {
|
|
59
|
+
id: 'role-1',
|
|
60
|
+
name: 'Owner',
|
|
61
|
+
description: null,
|
|
62
|
+
isSystemSeeded: true,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'membership-2',
|
|
67
|
+
roleId: 'role-2',
|
|
68
|
+
isOwner: false,
|
|
69
|
+
isBillingContact: false,
|
|
70
|
+
organisation: {
|
|
71
|
+
id: 'org-2',
|
|
72
|
+
name: 'Beta Operations',
|
|
73
|
+
slug: 'beta',
|
|
74
|
+
status: OrganisationStatus.ACTIVE,
|
|
75
|
+
},
|
|
76
|
+
role: {
|
|
77
|
+
id: 'role-2',
|
|
78
|
+
name: 'Viewer',
|
|
79
|
+
description: null,
|
|
80
|
+
isSystemSeeded: true,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const service = new OrganisationsService({} as never, prisma as never, {} as never);
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
service.listMine(
|
|
90
|
+
{
|
|
91
|
+
id: 'user-1',
|
|
92
|
+
email: 'user@example.com',
|
|
93
|
+
mobile: null,
|
|
94
|
+
displayName: 'User',
|
|
95
|
+
isActive: true,
|
|
96
|
+
},
|
|
97
|
+
{ limit: 1 },
|
|
98
|
+
),
|
|
99
|
+
).resolves.toEqual({
|
|
100
|
+
items: [
|
|
101
|
+
{
|
|
102
|
+
id: 'org-1',
|
|
103
|
+
name: 'Acme Operations',
|
|
104
|
+
slug: 'acme',
|
|
105
|
+
status: OrganisationStatus.ACTIVE,
|
|
106
|
+
membershipId: 'membership-1',
|
|
107
|
+
roleId: 'role-1',
|
|
108
|
+
role: {
|
|
109
|
+
id: 'role-1',
|
|
110
|
+
name: 'Owner',
|
|
111
|
+
description: null,
|
|
112
|
+
isSystemSeeded: true,
|
|
113
|
+
},
|
|
114
|
+
isOwner: true,
|
|
115
|
+
isBillingContact: true,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
nextCursor: 'membership-1',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(prisma.membership.findMany).toHaveBeenCalledWith(
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
where: {
|
|
124
|
+
userId: 'user-1',
|
|
125
|
+
status: MembershipStatus.ACTIVE,
|
|
126
|
+
organisation: {
|
|
127
|
+
status: OrganisationStatus.ACTIVE,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
take: 2,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('CORS environment config', () => {
|
|
137
|
+
it('is disabled by default', () => {
|
|
138
|
+
const env = validate(baseEnv());
|
|
139
|
+
|
|
140
|
+
expect(env.CORS_ENABLED).toBe(false);
|
|
141
|
+
expect(env.CORS_ORIGINS).toBe('');
|
|
142
|
+
expect(env.CORS_CREDENTIALS).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('accepts enabled CORS with exact allowed origins', () => {
|
|
146
|
+
const env = validate({
|
|
147
|
+
...baseEnv(),
|
|
148
|
+
CORS_ENABLED: 'true',
|
|
149
|
+
CORS_ORIGINS: 'http://localhost:5173, https://app.example.com',
|
|
150
|
+
CORS_CREDENTIALS: 'true',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(env.CORS_ENABLED).toBe(true);
|
|
154
|
+
expect(env.CORS_CREDENTIALS).toBe(true);
|
|
155
|
+
expect(parseCorsOrigins(env.CORS_ORIGINS)).toEqual([
|
|
156
|
+
'http://localhost:5173',
|
|
157
|
+
'https://app.example.com',
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rejects wildcard origins when credentials are enabled', () => {
|
|
162
|
+
expect(() =>
|
|
163
|
+
validate({
|
|
164
|
+
...baseEnv(),
|
|
165
|
+
CORS_ENABLED: 'true',
|
|
166
|
+
CORS_ORIGINS: '*',
|
|
167
|
+
CORS_CREDENTIALS: 'true',
|
|
168
|
+
}),
|
|
169
|
+
).toThrow(/CORS_ORIGINS cannot include \*/u);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
function baseEnv() {
|
|
174
|
+
return {
|
|
175
|
+
NODE_ENV: 'test',
|
|
176
|
+
PORT: '3000',
|
|
177
|
+
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/app_test',
|
|
178
|
+
JWT_SECRET: 'test-jwt-secret-at-least-32-characters',
|
|
179
|
+
AUTH_GOOGLE_ENABLED: 'false',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
2
|
+
import {
|
|
3
|
+
assertPermissionChangeWithinActor,
|
|
4
|
+
assertRoleWithinActorPermissions,
|
|
5
|
+
} from '../src/modules/access-control/application/role-permission-policy';
|
|
6
|
+
|
|
7
|
+
describe('assertRoleWithinActorPermissions', () => {
|
|
8
|
+
it('allows owners to assign any role', () => {
|
|
9
|
+
expect(() =>
|
|
10
|
+
assertRoleWithinActorPermissions({
|
|
11
|
+
actorIsOwner: true,
|
|
12
|
+
actorPermissionKeys: [],
|
|
13
|
+
rolePermissionKeys: ['billing.manage', 'roles.manage'],
|
|
14
|
+
}),
|
|
15
|
+
).not.toThrow();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('allows a role whose permissions are a subset of the actor', () => {
|
|
19
|
+
expect(() =>
|
|
20
|
+
assertRoleWithinActorPermissions({
|
|
21
|
+
actorIsOwner: false,
|
|
22
|
+
actorPermissionKeys: ['users.read', 'users.update', 'roles.manage'],
|
|
23
|
+
rolePermissionKeys: ['users.read', 'users.update'],
|
|
24
|
+
}),
|
|
25
|
+
).not.toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a role carrying a permission the actor does not hold', () => {
|
|
29
|
+
expect(() =>
|
|
30
|
+
assertRoleWithinActorPermissions({
|
|
31
|
+
actorIsOwner: false,
|
|
32
|
+
actorPermissionKeys: ['users.read'],
|
|
33
|
+
rolePermissionKeys: ['users.read', 'billing.manage'],
|
|
34
|
+
}),
|
|
35
|
+
).toThrow(/billing\.manage/u);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('assertPermissionChangeWithinActor', () => {
|
|
40
|
+
it('allows owners to make any change', () => {
|
|
41
|
+
expect(() =>
|
|
42
|
+
assertPermissionChangeWithinActor({
|
|
43
|
+
actorIsOwner: true,
|
|
44
|
+
actorPermissionKeys: [],
|
|
45
|
+
previousPermissionKeys: ['users.read'],
|
|
46
|
+
nextPermissionKeys: ['users.read', 'billing.manage'],
|
|
47
|
+
}),
|
|
48
|
+
).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('allows adding and removing permissions the actor holds', () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
assertPermissionChangeWithinActor({
|
|
54
|
+
actorIsOwner: false,
|
|
55
|
+
actorPermissionKeys: ['users.read', 'users.update'],
|
|
56
|
+
previousPermissionKeys: ['users.read'],
|
|
57
|
+
nextPermissionKeys: ['users.update'],
|
|
58
|
+
}),
|
|
59
|
+
).not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects adding a permission the actor does not hold', () => {
|
|
63
|
+
expect(() =>
|
|
64
|
+
assertPermissionChangeWithinActor({
|
|
65
|
+
actorIsOwner: false,
|
|
66
|
+
actorPermissionKeys: ['users.read'],
|
|
67
|
+
previousPermissionKeys: ['users.read'],
|
|
68
|
+
nextPermissionKeys: ['users.read', 'billing.manage'],
|
|
69
|
+
}),
|
|
70
|
+
).toThrow(/billing\.manage/u);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects revoking a permission the actor does not hold', () => {
|
|
74
|
+
expect(() =>
|
|
75
|
+
assertPermissionChangeWithinActor({
|
|
76
|
+
actorIsOwner: false,
|
|
77
|
+
actorPermissionKeys: ['users.read'],
|
|
78
|
+
previousPermissionKeys: ['users.read', 'billing.manage'],
|
|
79
|
+
nextPermissionKeys: ['users.read'],
|
|
80
|
+
}),
|
|
81
|
+
).toThrow(ForbiddenException);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('allows an unchanged set even when it contains permissions the actor lacks', () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
assertPermissionChangeWithinActor({
|
|
87
|
+
actorIsOwner: false,
|
|
88
|
+
actorPermissionKeys: [],
|
|
89
|
+
previousPermissionKeys: ['billing.manage'],
|
|
90
|
+
nextPermissionKeys: ['billing.manage'],
|
|
91
|
+
}),
|
|
92
|
+
).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Controller, Get } from '@nestjs/common';
|
|
2
2
|
import { MetadataScanner, Reflector } from '@nestjs/core';
|
|
3
3
|
import { AccessControlController } from '../src/modules/access-control/presentation/access-control.controller';
|
|
4
|
+
import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
|
|
4
5
|
import { RequirePermissions } from '../src/modules/access-control/presentation/permissions.decorator';
|
|
5
6
|
import { RouteRegistryValidator } from '../src/modules/access-control/application/route-registry.validator';
|
|
6
7
|
import { PermissionKey } from '../src/modules/access-control/types/permission-key';
|
|
8
|
+
import { routePermissionRegistry } from '../src/modules/access-control/types/route-permission-registry';
|
|
7
9
|
import { AuditController } from '../src/modules/audit/presentation/audit.controller';
|
|
8
10
|
import { AuthController } from '../src/modules/auth/presentation/auth.controller';
|
|
9
11
|
import { InvitationsController } from '../src/modules/invitations/presentation/invitations.controller';
|
|
@@ -14,6 +16,7 @@ import { SettingsController } from '../src/modules/settings/presentation/setting
|
|
|
14
16
|
|
|
15
17
|
const controllers = [
|
|
16
18
|
AccessControlController,
|
|
19
|
+
CurrentAccessControlController,
|
|
17
20
|
AuditController,
|
|
18
21
|
AuthController,
|
|
19
22
|
InvitationsController,
|
|
@@ -28,6 +31,15 @@ describe('RouteRegistryValidator', () => {
|
|
|
28
31
|
expect(() => createValidator(controllers).onApplicationBootstrap()).not.toThrow();
|
|
29
32
|
});
|
|
30
33
|
|
|
34
|
+
it('does not register the current access-control bootstrap route as permission-gated', () => {
|
|
35
|
+
expect(routePermissionRegistry).not.toContainEqual(
|
|
36
|
+
expect.objectContaining({
|
|
37
|
+
method: 'GET',
|
|
38
|
+
path: '/organisations/:orgId/access-control/me',
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
31
43
|
it('fails when a permission-gated route is missing from the registry', () => {
|
|
32
44
|
class DriftController {
|
|
33
45
|
probe() {
|
|
@@ -72,7 +72,101 @@ describe('Security invariants (e2e)', () => {
|
|
|
72
72
|
.expect(403);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('requires authentication for frontend bootstrap endpoints', async () => {
|
|
76
|
+
const { orgId } = await createUserAndOrg('bootstrap-auth');
|
|
77
|
+
|
|
78
|
+
await request(app.getHttpServer()).get('/organisations/mine').expect(401);
|
|
79
|
+
await request(app.getHttpServer()).get(`/organisations/${orgId}/access-control/me`).expect(401);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('lists only active current-user organisations with cursor pagination shape', async () => {
|
|
83
|
+
const first = await createUserAndOrg('mine-active');
|
|
84
|
+
const second = await createOrganisation(first.accessToken, 'mine-suspended');
|
|
85
|
+
|
|
86
|
+
await prisma.membership.update({
|
|
87
|
+
where: { id: second.membershipId },
|
|
88
|
+
data: { status: MembershipStatus.SUSPENDED },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const response = await request(app.getHttpServer())
|
|
92
|
+
.get('/organisations/mine')
|
|
93
|
+
.set('Authorization', `Bearer ${first.accessToken}`)
|
|
94
|
+
.expect(200);
|
|
95
|
+
|
|
96
|
+
expect(response.body).toEqual({
|
|
97
|
+
items: [
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
id: first.orgId,
|
|
100
|
+
membershipId: first.membershipId,
|
|
101
|
+
roleId: expect.any(String),
|
|
102
|
+
role: expect.objectContaining({ name: 'Owner' }),
|
|
103
|
+
isOwner: true,
|
|
104
|
+
isBillingContact: true,
|
|
105
|
+
}),
|
|
106
|
+
],
|
|
107
|
+
nextCursor: null,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns effective access-control context for an active member without roles.read', async () => {
|
|
112
|
+
const owner = await createUserAndOrg('access-me-owner');
|
|
113
|
+
const member = await createMemberInOrg(owner.orgId, 'access-me-member');
|
|
114
|
+
|
|
115
|
+
const response = await request(app.getHttpServer())
|
|
116
|
+
.get(`/organisations/${owner.orgId}/access-control/me`)
|
|
117
|
+
.set('Authorization', `Bearer ${member.accessToken}`)
|
|
118
|
+
.expect(200);
|
|
119
|
+
|
|
120
|
+
expect(response.body).toEqual({
|
|
121
|
+
orgId: owner.orgId,
|
|
122
|
+
membershipId: member.membershipId,
|
|
123
|
+
roleId: member.roleId,
|
|
124
|
+
isOwner: false,
|
|
125
|
+
isBillingContact: false,
|
|
126
|
+
permissionKeys: [],
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('denies access-control context to non-members', async () => {
|
|
131
|
+
const first = await createUserAndOrg('access-me-a');
|
|
132
|
+
const second = await createUserAndOrg('access-me-b');
|
|
133
|
+
|
|
134
|
+
await request(app.getHttpServer())
|
|
135
|
+
.get(`/organisations/${second.orgId}/access-control/me`)
|
|
136
|
+
.set('Authorization', `Bearer ${first.accessToken}`)
|
|
137
|
+
.expect(403);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it.each([MembershipStatus.SUSPENDED, MembershipStatus.REVOKED])(
|
|
141
|
+
'denies access-control context for %s memberships',
|
|
142
|
+
async (status) => {
|
|
143
|
+
const owner = await createUserAndOrg(`access-me-${status.toLowerCase()}`);
|
|
144
|
+
const member = await createMemberInOrg(owner.orgId, `member-${status.toLowerCase()}`);
|
|
145
|
+
|
|
146
|
+
await prisma.membership.update({
|
|
147
|
+
where: { id: member.membershipId },
|
|
148
|
+
data: { status },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await request(app.getHttpServer())
|
|
152
|
+
.get(`/organisations/${owner.orgId}/access-control/me`)
|
|
153
|
+
.set('Authorization', `Bearer ${member.accessToken}`)
|
|
154
|
+
.expect(403);
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
75
158
|
async function createUserAndOrg(label: string) {
|
|
159
|
+
const user = await createUser(label);
|
|
160
|
+
const org = await createOrganisation(user.accessToken, label);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
...user,
|
|
164
|
+
orgId: org.orgId,
|
|
165
|
+
membershipId: org.membershipId,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function createUser(label: string) {
|
|
76
170
|
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
77
171
|
const signup = await request(app.getHttpServer())
|
|
78
172
|
.post('/auth/signup')
|
|
@@ -83,7 +177,14 @@ describe('Security invariants (e2e)', () => {
|
|
|
83
177
|
})
|
|
84
178
|
.expect(201);
|
|
85
179
|
|
|
86
|
-
|
|
180
|
+
return {
|
|
181
|
+
accessToken: signup.body.accessToken as string,
|
|
182
|
+
userId: signup.body.user.id as string,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function createOrganisation(accessToken: string, label: string) {
|
|
187
|
+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
87
188
|
const org = await request(app.getHttpServer())
|
|
88
189
|
.post('/organisations')
|
|
89
190
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
@@ -94,9 +195,40 @@ describe('Security invariants (e2e)', () => {
|
|
|
94
195
|
.expect(201);
|
|
95
196
|
|
|
96
197
|
return {
|
|
97
|
-
accessToken,
|
|
98
198
|
orgId: org.body.organisation.id as string,
|
|
99
199
|
membershipId: org.body.membership.id as string,
|
|
100
200
|
};
|
|
101
201
|
}
|
|
202
|
+
|
|
203
|
+
async function createMemberInOrg(orgId: string, label: string) {
|
|
204
|
+
const user = await createUser(label);
|
|
205
|
+
const role = await prisma.role.findUniqueOrThrow({
|
|
206
|
+
where: {
|
|
207
|
+
orgId_name: {
|
|
208
|
+
orgId,
|
|
209
|
+
name: 'Viewer',
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
select: {
|
|
213
|
+
id: true,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
const membership = await prisma.membership.create({
|
|
217
|
+
data: {
|
|
218
|
+
userId: user.userId,
|
|
219
|
+
orgId,
|
|
220
|
+
roleId: role.id,
|
|
221
|
+
},
|
|
222
|
+
select: {
|
|
223
|
+
id: true,
|
|
224
|
+
roleId: true,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
...user,
|
|
230
|
+
membershipId: membership.id,
|
|
231
|
+
roleId: membership.roleId,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
102
234
|
});
|