@discover-cloud/shared 1.0.11 → 1.2.1
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/dist/authorization/permission-cache.service.d.ts +14 -1
- package/dist/authorization/permission-cache.service.js +84 -16
- package/dist/authorization/permissions.d.ts +19 -15
- package/dist/authorization/permissions.js +24 -21
- package/dist/dtos/auth-service.dto.d.ts +13 -0
- package/dist/dtos/cloud-service.dto.d.ts +25 -10
- package/dist/dtos/insights-service.dto.d.ts +24 -8
- package/dist/dtos/response.dto.d.ts +80 -10
- package/dist/dtos/response.dto.js +23 -4
- package/dist/dtos/user-service.dto.d.ts +7 -0
- package/dist/enums/domain.enums.d.ts +45 -14
- package/dist/enums/domain.enums.js +40 -6
- package/dist/enums/permissions.enums.d.ts +12 -52
- package/dist/enums/permissions.enums.js +55 -59
- package/dist/errors/app-error.d.ts +17 -14
- package/dist/errors/app-error.js +19 -15
- package/dist/errors/http-errors.d.ts +12 -5
- package/dist/errors/http-errors.js +27 -5
- package/dist/http/service-client.d.ts +37 -12
- package/dist/http/service-client.js +43 -17
- package/dist/jwt/internal-jwt-verifier.d.ts +9 -3
- package/dist/jwt/internal-jwt-verifier.js +49 -26
- package/dist/middleware/authorize.middleware.d.ts +38 -6
- package/dist/middleware/authorize.middleware.js +62 -37
- package/dist/middleware/error-handler.middleware.d.ts +21 -7
- package/dist/middleware/error-handler.middleware.js +42 -14
- package/dist/middleware/request-id.middleware.d.ts +12 -10
- package/dist/middleware/request-id.middleware.js +15 -15
- package/dist/middleware/validate.middleware.d.ts +22 -13
- package/dist/middleware/validate.middleware.js +25 -16
- package/dist/middleware/validated-merge.middleware.js +1 -2
- package/dist/types/express.types.d.ts +37 -32
- package/dist/types/express.types.js +13 -11
- package/dist/utils/date.utils.d.ts +18 -4
- package/dist/utils/date.utils.js +18 -4
- package/dist/utils/env.d.ts +46 -0
- package/dist/utils/env.js +61 -0
- package/dist/utils/env.utils.d.ts +46 -0
- package/dist/utils/env.utils.js +61 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/logger.utils.d.ts +31 -16
- package/dist/utils/logger.utils.js +55 -20
- package/dist/utils/response.utils.d.ts +47 -5
- package/dist/utils/response.utils.js +50 -7
- package/package.json +1 -1
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { GlobalPermission, AccountRole } from "../enums";
|
|
2
2
|
export declare class PermissionCacheService {
|
|
3
3
|
private readonly client;
|
|
4
|
-
private connected;
|
|
5
4
|
constructor();
|
|
5
|
+
disconnect(): Promise<void>;
|
|
6
6
|
resolve(accountId: string, accountRole: AccountRole): Promise<GlobalPermission[]>;
|
|
7
7
|
invalidate(accountId: string): Promise<void>;
|
|
8
8
|
invalidateMany(accountIds: string[]): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* isReady
|
|
11
|
+
* Authoritative liveness check using the client's own state machine.
|
|
12
|
+
*
|
|
13
|
+
* Replaces a manually tracked `connected` boolean, which could drift
|
|
14
|
+
* after reconnect cycles — the node-redis client reconnects automatically
|
|
15
|
+
* after a transient failure, but a boolean set only on the first "ready"
|
|
16
|
+
* event would not reflect subsequent disconnects and reconnects.
|
|
17
|
+
*
|
|
18
|
+
* `client.isReady` is kept current by the client's internal state machine
|
|
19
|
+
* and is the correct primitive for this guard.
|
|
20
|
+
*/
|
|
21
|
+
private isReady;
|
|
9
22
|
private key;
|
|
10
23
|
private safeGet;
|
|
11
24
|
private safeSet;
|
|
@@ -24,6 +24,20 @@ const permissions_1 = require("./permissions");
|
|
|
24
24
|
* - Suspension → invalidate(accountId)
|
|
25
25
|
* - Account deletion → invalidate(accountId)
|
|
26
26
|
*
|
|
27
|
+
* Connection lifecycle:
|
|
28
|
+
* - isReady() is the authoritative liveness check — it reflects the actual
|
|
29
|
+
* client state (ready / connecting / reconnecting / closing) rather than a
|
|
30
|
+
* manually tracked boolean that can drift after automatic reconnects.
|
|
31
|
+
* - Call disconnect() in your SIGTERM handler to drain in-flight commands
|
|
32
|
+
* and release the TCP socket before the process exits.
|
|
33
|
+
*
|
|
34
|
+
* Fault tolerance:
|
|
35
|
+
* - All read/write paths guard via isReady() and fall back gracefully.
|
|
36
|
+
* - On a cold-cache miss during Redis unavailability, permissions are derived
|
|
37
|
+
* from the static role map (degraded but functional — no request is blocked).
|
|
38
|
+
* - safeGet validates the parsed cache value shape before returning it;
|
|
39
|
+
* malformed entries are treated as cache misses rather than runtime errors.
|
|
40
|
+
*
|
|
27
41
|
* NOTE: Uses console.* for logging — shared cannot depend on a service-specific
|
|
28
42
|
* logger (pino, winston, etc.). Each service's own logger will capture these
|
|
29
43
|
* via stdout in production.
|
|
@@ -32,21 +46,39 @@ const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours
|
|
|
32
46
|
const KEY_PREFIX = "perms";
|
|
33
47
|
class PermissionCacheService {
|
|
34
48
|
constructor() {
|
|
35
|
-
this.connected = false;
|
|
36
49
|
this.client = (0, redis_1.createClient)({
|
|
37
|
-
url: process.env
|
|
50
|
+
url: process.env["REDIS_URL"] ?? "redis://localhost:6379",
|
|
38
51
|
});
|
|
39
52
|
this.client.on("error", (err) => {
|
|
40
53
|
console.error("[PermissionCache] Redis client error", err);
|
|
41
54
|
});
|
|
42
55
|
this.client.on("ready", () => {
|
|
43
|
-
|
|
44
|
-
console.info("[PermissionCache] Redis connected");
|
|
56
|
+
console.info("[PermissionCache] Redis connected and ready");
|
|
45
57
|
});
|
|
58
|
+
this.client.on("reconnecting", () => {
|
|
59
|
+
console.warn("[PermissionCache] Redis reconnecting...");
|
|
60
|
+
});
|
|
61
|
+
// Connect asynchronously — all read/write methods guard via isReady()
|
|
62
|
+
// and fall back gracefully, so startup is non-blocking.
|
|
46
63
|
this.client.connect().catch((err) => {
|
|
47
|
-
console.error("[PermissionCache] Redis connection failed", err);
|
|
64
|
+
console.error("[PermissionCache] Redis initial connection failed", err);
|
|
48
65
|
});
|
|
49
66
|
}
|
|
67
|
+
/* ----------------------------------------------------------------
|
|
68
|
+
disconnect
|
|
69
|
+
Gracefully closes the Redis connection. Call during service shutdown
|
|
70
|
+
(SIGTERM handler) to drain in-flight commands and release the TCP
|
|
71
|
+
socket before the process exits.
|
|
72
|
+
---------------------------------------------------------------- */
|
|
73
|
+
async disconnect() {
|
|
74
|
+
try {
|
|
75
|
+
await this.client.quit();
|
|
76
|
+
console.info("[PermissionCache] Redis connection closed");
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.error("[PermissionCache] Error during disconnect", err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
50
82
|
/* ----------------------------------------------------------------
|
|
51
83
|
resolve
|
|
52
84
|
Returns permissions for an account, loading from cache or deriving
|
|
@@ -59,12 +91,12 @@ class PermissionCacheService {
|
|
|
59
91
|
const cached = await this.safeGet(accountId);
|
|
60
92
|
if (cached !== null)
|
|
61
93
|
return cached;
|
|
62
|
-
// Cache miss — derive from role map (no DB call
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
this.safeSet(accountId, perms)
|
|
94
|
+
// Cache miss — derive from role map (no DB call needed; the role is
|
|
95
|
+
// already verified in the JWT payload by the time we get here).
|
|
96
|
+
const perms = [...(permissions_1.globalRolePermissions[accountRole] ?? [])];
|
|
97
|
+
// Write back fire-and-forget — safeSet swallows errors internally,
|
|
98
|
+
// so we don't await or attach a catch here.
|
|
99
|
+
void this.safeSet(accountId, perms);
|
|
68
100
|
return perms;
|
|
69
101
|
}
|
|
70
102
|
/* ----------------------------------------------------------------
|
|
@@ -73,22 +105,32 @@ class PermissionCacheService {
|
|
|
73
105
|
Call on: role change, suspension, account deletion.
|
|
74
106
|
---------------------------------------------------------------- */
|
|
75
107
|
async invalidate(accountId) {
|
|
108
|
+
if (!this.isReady()) {
|
|
109
|
+
// Skip deletion — the stale entry will expire via TTL at worst.
|
|
110
|
+
console.warn(`[PermissionCache] Skipping invalidation for ${accountId} — Redis not ready`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
76
113
|
try {
|
|
77
114
|
await this.client.del(this.key(accountId));
|
|
78
115
|
console.info(`[PermissionCache] Invalidated: ${accountId}`);
|
|
79
116
|
}
|
|
80
117
|
catch (err) {
|
|
81
|
-
// Log but don't throw — stale entry expires via TTL at worst
|
|
118
|
+
// Log but don't throw — stale entry expires via TTL at worst.
|
|
82
119
|
console.error(`[PermissionCache] Failed to invalidate: ${accountId}`, err);
|
|
83
120
|
}
|
|
84
121
|
}
|
|
85
122
|
/* ----------------------------------------------------------------
|
|
86
123
|
invalidateMany
|
|
87
124
|
Batch invalidation — use when a role definition changes globally.
|
|
125
|
+
Sends a single DEL with multiple keys (O(n) in Redis, one round-trip).
|
|
88
126
|
---------------------------------------------------------------- */
|
|
89
127
|
async invalidateMany(accountIds) {
|
|
90
128
|
if (accountIds.length === 0)
|
|
91
129
|
return;
|
|
130
|
+
if (!this.isReady()) {
|
|
131
|
+
console.warn(`[PermissionCache] Skipping batch invalidation (${accountIds.length} entries) — Redis not ready`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
92
134
|
try {
|
|
93
135
|
const keys = accountIds.map((id) => this.key(id));
|
|
94
136
|
await this.client.del(keys);
|
|
@@ -101,28 +143,54 @@ class PermissionCacheService {
|
|
|
101
143
|
/* ----------------------------------------------------------------
|
|
102
144
|
Private helpers
|
|
103
145
|
---------------------------------------------------------------- */
|
|
146
|
+
/**
|
|
147
|
+
* isReady
|
|
148
|
+
* Authoritative liveness check using the client's own state machine.
|
|
149
|
+
*
|
|
150
|
+
* Replaces a manually tracked `connected` boolean, which could drift
|
|
151
|
+
* after reconnect cycles — the node-redis client reconnects automatically
|
|
152
|
+
* after a transient failure, but a boolean set only on the first "ready"
|
|
153
|
+
* event would not reflect subsequent disconnects and reconnects.
|
|
154
|
+
*
|
|
155
|
+
* `client.isReady` is kept current by the client's internal state machine
|
|
156
|
+
* and is the correct primitive for this guard.
|
|
157
|
+
*/
|
|
158
|
+
isReady() {
|
|
159
|
+
return this.client.isReady;
|
|
160
|
+
}
|
|
104
161
|
key(accountId) {
|
|
105
162
|
return `${KEY_PREFIX}:${accountId}`;
|
|
106
163
|
}
|
|
107
164
|
async safeGet(accountId) {
|
|
108
|
-
if (!this.
|
|
165
|
+
if (!this.isReady())
|
|
109
166
|
return null;
|
|
110
167
|
try {
|
|
111
168
|
const raw = await this.client.get(this.key(accountId));
|
|
112
169
|
if (!raw)
|
|
113
170
|
return null;
|
|
114
|
-
|
|
171
|
+
const parsed = JSON.parse(raw);
|
|
172
|
+
// Validate shape before trusting the cached value. A malformed entry
|
|
173
|
+
// (e.g. from a prior schema change or a manual key mutation) should
|
|
174
|
+
// produce a cache miss, not a silent type error downstream.
|
|
175
|
+
if (!Array.isArray(parsed)) {
|
|
176
|
+
console.warn(`[PermissionCache] Unexpected cache shape for ${accountId}, treating as miss`);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
return parsed;
|
|
115
180
|
}
|
|
116
181
|
catch (err) {
|
|
182
|
+
// JSON.parse failure or Redis error — bypass the cache gracefully.
|
|
117
183
|
console.warn(`[PermissionCache] Cache get failed for ${accountId}, bypassing`, err);
|
|
118
184
|
return null;
|
|
119
185
|
}
|
|
120
186
|
}
|
|
121
187
|
async safeSet(accountId, perms) {
|
|
122
|
-
if (!this.
|
|
188
|
+
if (!this.isReady())
|
|
123
189
|
return;
|
|
124
190
|
try {
|
|
125
|
-
await this.client.set(this.key(accountId), JSON.stringify(perms), {
|
|
191
|
+
await this.client.set(this.key(accountId), JSON.stringify(perms), {
|
|
192
|
+
EX: CACHE_TTL_SECONDS,
|
|
193
|
+
});
|
|
126
194
|
}
|
|
127
195
|
catch (err) {
|
|
128
196
|
console.warn(`[PermissionCache] Cache set failed for ${accountId}`, err);
|
|
@@ -8,10 +8,11 @@ import { AccessContext } from "../types";
|
|
|
8
8
|
* derives from this map on a cold cache miss.
|
|
9
9
|
*
|
|
10
10
|
* Design principles:
|
|
11
|
-
* - SUPERADMIN gets all permissions (dynamically, not
|
|
11
|
+
* - SUPERADMIN gets all permissions (dynamically via Object.values, not a
|
|
12
|
+
* hardcoded list — so new permissions are automatically granted on addition)
|
|
12
13
|
* - Each role has only the minimum permissions it needs (least privilege)
|
|
13
14
|
* - Permissions are additive — no role inherits from another at runtime
|
|
14
|
-
* - Adding a new permission: add to
|
|
15
|
+
* - Adding a new permission: add to GlobalPermission enum, then grant it here
|
|
15
16
|
*
|
|
16
17
|
* ┌─────────────┬──────────────────────────────────────────────────────┐
|
|
17
18
|
* │ Role │ Key permissions │
|
|
@@ -27,7 +28,7 @@ export declare const globalRolePermissions: Record<AccountRole, readonly GlobalP
|
|
|
27
28
|
/**
|
|
28
29
|
* ORG ROLE PERMISSION MAP (future work)
|
|
29
30
|
* ────────────────────────────────────────
|
|
30
|
-
* Org permissions are NOT in the JWT — they're fetched from the DB
|
|
31
|
+
* Org permissions are NOT embedded in the JWT — they're fetched from the DB
|
|
31
32
|
* by tenant middleware within each service because:
|
|
32
33
|
* 1. Org membership changes frequently (invites, role changes, removal)
|
|
33
34
|
* 2. A user can be in multiple orgs with different roles
|
|
@@ -35,15 +36,20 @@ export declare const globalRolePermissions: Record<AccountRole, readonly GlobalP
|
|
|
35
36
|
*
|
|
36
37
|
* This map is used by tenant middleware for the DB-free fast path:
|
|
37
38
|
* if the org role is already known (e.g. from a prior DB fetch cached in
|
|
38
|
-
* Redis), derive permissions
|
|
39
|
+
* Redis), derive permissions here rather than re-querying the DB.
|
|
40
|
+
*
|
|
41
|
+
* NOTE: All entries use OrgPermission values only — no cross-namespace mixing
|
|
42
|
+
* with GlobalPermission. Org admin membership management is expressed via
|
|
43
|
+
* the dedicated OrgPermission.* values below.
|
|
39
44
|
*/
|
|
40
45
|
export declare const orgRolePermissions: Record<OrganizationRole, readonly OrgPermission[]>;
|
|
41
46
|
/**
|
|
42
47
|
* hasGlobalPermission
|
|
43
|
-
* Checks if a GlobalPermission is in the AccessContext's perms array.
|
|
44
|
-
* Perms
|
|
48
|
+
* Checks if a GlobalPermission is present in the AccessContext's perms array.
|
|
49
|
+
* Perms are populated from the Redis permission cache (derived from the role
|
|
50
|
+
* map on a miss) — never read directly from the JWT.
|
|
45
51
|
*
|
|
46
|
-
*
|
|
52
|
+
* Machine contexts always return false — they carry no user permissions.
|
|
47
53
|
*/
|
|
48
54
|
export declare const hasGlobalPermission: (ctx: AccessContext, permission: GlobalPermission) => boolean;
|
|
49
55
|
/**
|
|
@@ -51,18 +57,16 @@ export declare const hasGlobalPermission: (ctx: AccessContext, permission: Globa
|
|
|
51
57
|
* Unified permission check for both GlobalPermission and OrgPermission.
|
|
52
58
|
*
|
|
53
59
|
* GlobalPermission → checked against ctx.perms (Redis cache, derived from role)
|
|
54
|
-
* OrgPermission → returns false here; handled by tenant
|
|
55
|
-
* service via DB lookup. This function
|
|
56
|
-
* the place for org permission checks
|
|
57
|
-
*
|
|
58
|
-
* This makes the boundary explicit: callers that need org permission checks
|
|
59
|
-
* should use their service's tenant middleware, not isAllowed().
|
|
60
|
+
* OrgPermission → intentionally returns false here; handled by tenant
|
|
61
|
+
* middleware in each service via DB lookup. This function
|
|
62
|
+
* is not the place for org permission checks — the boundary
|
|
63
|
+
* is explicit by design.
|
|
60
64
|
*/
|
|
61
65
|
export declare const isAllowed: (ctx: AccessContext, permission: GlobalPermission | OrgPermission) => boolean;
|
|
62
66
|
/**
|
|
63
67
|
* isSuperAdmin
|
|
64
|
-
* Convenience guard for
|
|
65
|
-
*
|
|
68
|
+
* Convenience guard for cases where superadmin-only logic is needed
|
|
69
|
+
* beyond a single permission check (e.g. bypassing org scoping entirely).
|
|
66
70
|
*/
|
|
67
71
|
export declare const isSuperAdmin: (ctx: AccessContext) => boolean;
|
|
68
72
|
/**
|
|
@@ -10,10 +10,11 @@ const enums_1 = require("../enums");
|
|
|
10
10
|
* derives from this map on a cold cache miss.
|
|
11
11
|
*
|
|
12
12
|
* Design principles:
|
|
13
|
-
* - SUPERADMIN gets all permissions (dynamically, not
|
|
13
|
+
* - SUPERADMIN gets all permissions (dynamically via Object.values, not a
|
|
14
|
+
* hardcoded list — so new permissions are automatically granted on addition)
|
|
14
15
|
* - Each role has only the minimum permissions it needs (least privilege)
|
|
15
16
|
* - Permissions are additive — no role inherits from another at runtime
|
|
16
|
-
* - Adding a new permission: add to
|
|
17
|
+
* - Adding a new permission: add to GlobalPermission enum, then grant it here
|
|
17
18
|
*
|
|
18
19
|
* ┌─────────────┬──────────────────────────────────────────────────────┐
|
|
19
20
|
* │ Role │ Key permissions │
|
|
@@ -44,25 +45,25 @@ exports.globalRolePermissions = {
|
|
|
44
45
|
enums_1.GlobalPermission.SUPPORT_WRITE,
|
|
45
46
|
],
|
|
46
47
|
[enums_1.AccountRole.SUPPORT]: [
|
|
47
|
-
// Read-only access to user data and cost data for support purposes
|
|
48
|
-
// Cannot write, cannot manage accounts, cannot access system internals
|
|
48
|
+
// Read-only access to user data and cost data for support purposes.
|
|
49
|
+
// Cannot write, cannot manage accounts, cannot access system internals.
|
|
49
50
|
enums_1.GlobalPermission.VIEW_ANY_ACCOUNT,
|
|
50
51
|
enums_1.GlobalPermission.VIEW_ANY_COST_DATA,
|
|
51
52
|
enums_1.GlobalPermission.SUPPORT_VIEW,
|
|
52
53
|
],
|
|
53
54
|
[enums_1.AccountRole.MODERATOR]: [
|
|
54
|
-
//
|
|
55
|
+
// Content moderation only — no access to user accounts or cost data.
|
|
55
56
|
enums_1.GlobalPermission.MODERATE_CONTENT,
|
|
56
57
|
],
|
|
57
|
-
// Standard users
|
|
58
|
-
// All their data access is
|
|
58
|
+
// Standard users carry zero global permissions.
|
|
59
|
+
// All their data access is gated at the service layer
|
|
59
60
|
// (they can only see their own cost data, their own account, etc.)
|
|
60
61
|
[enums_1.AccountRole.USER]: [],
|
|
61
62
|
};
|
|
62
63
|
/**
|
|
63
64
|
* ORG ROLE PERMISSION MAP (future work)
|
|
64
65
|
* ────────────────────────────────────────
|
|
65
|
-
* Org permissions are NOT in the JWT — they're fetched from the DB
|
|
66
|
+
* Org permissions are NOT embedded in the JWT — they're fetched from the DB
|
|
66
67
|
* by tenant middleware within each service because:
|
|
67
68
|
* 1. Org membership changes frequently (invites, role changes, removal)
|
|
68
69
|
* 2. A user can be in multiple orgs with different roles
|
|
@@ -70,12 +71,15 @@ exports.globalRolePermissions = {
|
|
|
70
71
|
*
|
|
71
72
|
* This map is used by tenant middleware for the DB-free fast path:
|
|
72
73
|
* if the org role is already known (e.g. from a prior DB fetch cached in
|
|
73
|
-
* Redis), derive permissions
|
|
74
|
+
* Redis), derive permissions here rather than re-querying the DB.
|
|
75
|
+
*
|
|
76
|
+
* NOTE: All entries use OrgPermission values only — no cross-namespace mixing
|
|
77
|
+
* with GlobalPermission. Org admin membership management is expressed via
|
|
78
|
+
* the dedicated OrgPermission.* values below.
|
|
74
79
|
*/
|
|
75
80
|
exports.orgRolePermissions = {
|
|
76
81
|
[enums_1.OrganizationRole.OWNER]: Object.values(enums_1.OrgPermission), // Full org control
|
|
77
82
|
[enums_1.OrganizationRole.ADMIN]: [
|
|
78
|
-
enums_1.GlobalPermission.MANAGE_ACCOUNTS, // intentional re-use shape — org admin manages org members
|
|
79
83
|
enums_1.OrgPermission.MANAGE_ORG_SETTINGS,
|
|
80
84
|
enums_1.OrgPermission.INVITE_MEMBERS,
|
|
81
85
|
enums_1.OrgPermission.REMOVE_MEMBERS,
|
|
@@ -114,10 +118,11 @@ exports.orgRolePermissions = {
|
|
|
114
118
|
==================================================================== */
|
|
115
119
|
/**
|
|
116
120
|
* hasGlobalPermission
|
|
117
|
-
* Checks if a GlobalPermission is in the AccessContext's perms array.
|
|
118
|
-
* Perms
|
|
121
|
+
* Checks if a GlobalPermission is present in the AccessContext's perms array.
|
|
122
|
+
* Perms are populated from the Redis permission cache (derived from the role
|
|
123
|
+
* map on a miss) — never read directly from the JWT.
|
|
119
124
|
*
|
|
120
|
-
*
|
|
125
|
+
* Machine contexts always return false — they carry no user permissions.
|
|
121
126
|
*/
|
|
122
127
|
const hasGlobalPermission = (ctx, permission) => {
|
|
123
128
|
if (ctx.kind !== "human")
|
|
@@ -130,12 +135,10 @@ exports.hasGlobalPermission = hasGlobalPermission;
|
|
|
130
135
|
* Unified permission check for both GlobalPermission and OrgPermission.
|
|
131
136
|
*
|
|
132
137
|
* GlobalPermission → checked against ctx.perms (Redis cache, derived from role)
|
|
133
|
-
* OrgPermission → returns false here; handled by tenant
|
|
134
|
-
* service via DB lookup. This function
|
|
135
|
-
* the place for org permission checks
|
|
136
|
-
*
|
|
137
|
-
* This makes the boundary explicit: callers that need org permission checks
|
|
138
|
-
* should use their service's tenant middleware, not isAllowed().
|
|
138
|
+
* OrgPermission → intentionally returns false here; handled by tenant
|
|
139
|
+
* middleware in each service via DB lookup. This function
|
|
140
|
+
* is not the place for org permission checks — the boundary
|
|
141
|
+
* is explicit by design.
|
|
139
142
|
*/
|
|
140
143
|
const isAllowed = (ctx, permission) => {
|
|
141
144
|
if (ctx.kind !== "human")
|
|
@@ -150,8 +153,8 @@ const isAllowed = (ctx, permission) => {
|
|
|
150
153
|
exports.isAllowed = isAllowed;
|
|
151
154
|
/**
|
|
152
155
|
* isSuperAdmin
|
|
153
|
-
* Convenience guard for
|
|
154
|
-
*
|
|
156
|
+
* Convenience guard for cases where superadmin-only logic is needed
|
|
157
|
+
* beyond a single permission check (e.g. bypassing org scoping entirely).
|
|
155
158
|
*/
|
|
156
159
|
const isSuperAdmin = (ctx) => {
|
|
157
160
|
if (ctx.kind !== "human")
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { AccountStatus } from "../enums";
|
|
2
|
+
/**
|
|
3
|
+
* AUTH SERVICE DTOs (@discover-cloud/shared)
|
|
4
|
+
* ─────────────────────────────────────────────
|
|
5
|
+
* Internal data shapes produced by the auth service.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY RULE: DTOs containing secrets (token, hash fields) must NEVER
|
|
8
|
+
* be returned directly in HTTP responses. They exist so internal service
|
|
9
|
+
* methods have typed return values — mappers are responsible for stripping
|
|
10
|
+
* secrets before anything reaches the wire.
|
|
11
|
+
*
|
|
12
|
+
* Secret fields are annotated with // SECRET — internal use only
|
|
13
|
+
* to make accidental exposure visible at a glance during code review.
|
|
14
|
+
*/
|
|
2
15
|
export interface AccountDto {
|
|
3
16
|
id: string;
|
|
4
17
|
email: string;
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { CloudProvider, CloudAccountStatus, AwsAuthMethod, GcpAuthMethod, AzureAuthMethod, SyncStatus, SyncType } from "../enums";
|
|
2
2
|
/**
|
|
3
|
-
* CLOUD SERVICE DTOs
|
|
4
|
-
*
|
|
5
|
-
* Shapes
|
|
3
|
+
* CLOUD SERVICE DTOs (@discover-cloud/shared)
|
|
4
|
+
* ─────────────────────────────────────────────
|
|
5
|
+
* Shapes produced and consumed by the cloud service.
|
|
6
6
|
*
|
|
7
|
-
* Date
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Date handling:
|
|
8
|
+
* JSON has no Date type. Domain models use Date internally; these DTOs
|
|
9
|
+
* are the serialised form at the HTTP boundary — all timestamps are ISO 8601 strings.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Credential handling:
|
|
12
|
+
* CloudAccountDto intentionally omits encryptedCredentials — credentials
|
|
13
|
+
* are write-only. The connect DTOs accept credentials as `unknown` because
|
|
14
|
+
* the shape differs per provider and per authMethod; Zod schemas in the
|
|
15
|
+
* cloud service validate the concrete shape at runtime.
|
|
13
16
|
*
|
|
14
|
-
* SyncJobDto
|
|
15
|
-
* by the
|
|
17
|
+
* SyncJobDto:
|
|
18
|
+
* Read-only. Sync jobs are created internally by the scheduler and exposed
|
|
19
|
+
* for status polling only — clients never POST a sync job directly.
|
|
20
|
+
*
|
|
21
|
+
* Provider push DTOs (CloudCostRecordDto, CloudResourceDto, CloudBudgetDto):
|
|
22
|
+
* Used when cloud-service pushes collected data to insights-service.
|
|
23
|
+
* They include cloudAccountId for scoping — insights-service uses this to
|
|
24
|
+
* associate records with the correct account without re-fetching.
|
|
16
25
|
*/
|
|
17
26
|
export interface CloudAccountDto {
|
|
18
27
|
id: string;
|
|
@@ -27,16 +36,19 @@ export interface CloudAccountDto {
|
|
|
27
36
|
createdAt: string;
|
|
28
37
|
updatedAt: string;
|
|
29
38
|
}
|
|
39
|
+
/** Credential payload for a new AWS cloud account connection. */
|
|
30
40
|
export interface ConnectAwsAccountDto {
|
|
31
41
|
alias: string;
|
|
32
42
|
authMethod: AwsAuthMethod;
|
|
33
43
|
credentials: unknown;
|
|
34
44
|
}
|
|
45
|
+
/** Credential payload for a new GCP cloud account connection. */
|
|
35
46
|
export interface ConnectGcpAccountDto {
|
|
36
47
|
alias: string;
|
|
37
48
|
authMethod: GcpAuthMethod;
|
|
38
49
|
credentials: unknown;
|
|
39
50
|
}
|
|
51
|
+
/** Credential payload for a new Azure cloud account connection. */
|
|
40
52
|
export interface ConnectAzureAccountDto {
|
|
41
53
|
alias: string;
|
|
42
54
|
authMethod: AzureAuthMethod;
|
|
@@ -57,6 +69,7 @@ export interface SyncJobDto {
|
|
|
57
69
|
updatedAt: string;
|
|
58
70
|
}
|
|
59
71
|
export interface CloudCostRecordDto {
|
|
72
|
+
cloudAccountId: string;
|
|
60
73
|
provider: CloudProvider;
|
|
61
74
|
service: string;
|
|
62
75
|
region: string;
|
|
@@ -67,6 +80,7 @@ export interface CloudCostRecordDto {
|
|
|
67
80
|
usageType: string;
|
|
68
81
|
}
|
|
69
82
|
export interface CloudResourceDto {
|
|
83
|
+
cloudAccountId: string;
|
|
70
84
|
provider: CloudProvider;
|
|
71
85
|
resourceType: string;
|
|
72
86
|
resourceId: string;
|
|
@@ -77,6 +91,7 @@ export interface CloudResourceDto {
|
|
|
77
91
|
metadata: Record<string, unknown>;
|
|
78
92
|
}
|
|
79
93
|
export interface CloudBudgetDto {
|
|
94
|
+
cloudAccountId: string;
|
|
80
95
|
name: string;
|
|
81
96
|
limitAmount: number;
|
|
82
97
|
currency: string;
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { CloudProvider } from "../enums";
|
|
2
2
|
/**
|
|
3
|
-
* INSIGHTS SERVICE DTOs
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
3
|
+
* INSIGHTS SERVICE DTOs (@discover-cloud/shared)
|
|
4
|
+
* ─────────────────────────────────────────────────
|
|
5
|
+
* Four groups of types, each with a distinct role:
|
|
6
|
+
*
|
|
7
|
+
* Push payloads — cloud-service → insights-service (internal HTTP).
|
|
8
|
+
* Carry raw provider data collected during a sync job.
|
|
9
|
+
*
|
|
10
|
+
* Record DTOs — insights-service → frontend (public HTTP).
|
|
11
|
+
* Stored/enriched records returned to clients.
|
|
12
|
+
* All timestamps are ISO 8601 strings (JSON has no Date).
|
|
13
|
+
*
|
|
14
|
+
* Query DTO — internal query params passed between service layers.
|
|
15
|
+
* Never crosses an HTTP boundary — Date is fine here.
|
|
16
|
+
*
|
|
17
|
+
* Summary DTOs — assembled aggregations with no corresponding domain
|
|
18
|
+
* model or mapper. Built directly from DB query results.
|
|
9
19
|
*/
|
|
10
20
|
export interface PushCostItem {
|
|
11
21
|
service: string;
|
|
@@ -54,7 +64,7 @@ export interface CostRecordDto {
|
|
|
54
64
|
id: string;
|
|
55
65
|
cloudAccountId: string;
|
|
56
66
|
userId: string;
|
|
57
|
-
provider:
|
|
67
|
+
provider: CloudProvider;
|
|
58
68
|
service: string;
|
|
59
69
|
region: string;
|
|
60
70
|
amount: number;
|
|
@@ -68,7 +78,7 @@ export interface ResourceRecordDto {
|
|
|
68
78
|
id: string;
|
|
69
79
|
cloudAccountId: string;
|
|
70
80
|
userId: string;
|
|
71
|
-
provider:
|
|
81
|
+
provider: CloudProvider;
|
|
72
82
|
resourceType: string;
|
|
73
83
|
resourceId: string;
|
|
74
84
|
region: string;
|
|
@@ -93,6 +103,12 @@ export interface BudgetRecordDto {
|
|
|
93
103
|
isForecastOver: boolean;
|
|
94
104
|
recordedAt: string;
|
|
95
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* CostQueryDto
|
|
108
|
+
* Used by the insights-service query layer internally.
|
|
109
|
+
* Date fields are fine here — this never reaches JSON serialisation.
|
|
110
|
+
* If this ever needs to cross HTTP, convert from/to to ISO 8601 strings.
|
|
111
|
+
*/
|
|
96
112
|
export interface CostQueryDto {
|
|
97
113
|
userId?: string;
|
|
98
114
|
cloudAccountId?: string;
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RESPONSE DTOs (@discover-cloud/shared)
|
|
3
|
+
* ─────────────────────────────────────────
|
|
4
|
+
* Shared HTTP response shapes used across all services.
|
|
5
|
+
* Every response — success or error — is wrapped in the ApiSuccessResponse
|
|
6
|
+
* or ApiErrorResponse envelope so clients have a consistent shape to key on.
|
|
7
|
+
*
|
|
8
|
+
* Envelope shape:
|
|
9
|
+
* {
|
|
10
|
+
* success: true,
|
|
11
|
+
* data: T,
|
|
12
|
+
* meta: { requestId, timestamp }
|
|
13
|
+
* }
|
|
14
|
+
* or
|
|
15
|
+
* {
|
|
16
|
+
* success: false,
|
|
17
|
+
* error: { code, message, details? },
|
|
18
|
+
* meta: { requestId, timestamp }
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* The success() and failure() utils in shared/utils construct these
|
|
22
|
+
* envelopes — don't build them manually in controllers.
|
|
23
|
+
*/
|
|
1
24
|
export interface ApiMeta {
|
|
2
25
|
requestId: string;
|
|
3
26
|
timestamp: string;
|
|
@@ -17,25 +40,37 @@ export interface ApiErrorResponse {
|
|
|
17
40
|
meta: ApiMeta;
|
|
18
41
|
}
|
|
19
42
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
43
|
+
* TokensResponseDto
|
|
44
|
+
* Returned on successful login or token refresh.
|
|
45
|
+
*
|
|
46
|
+
* accessToken — short-lived JWT returned in the response body.
|
|
47
|
+
* refreshToken — long-lived token sent as an HttpOnly cookie by the auth
|
|
48
|
+
* service; it is intentionally absent from this DTO.
|
|
49
|
+
* Mobile clients that cannot use cookies should request a
|
|
50
|
+
* separate endpoint that returns the refresh token explicitly.
|
|
25
51
|
*/
|
|
26
52
|
export interface TokensResponseDto {
|
|
27
53
|
accessToken: string;
|
|
28
54
|
}
|
|
29
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* MessageResponseDto
|
|
57
|
+
* Use for simple confirmations where no structured data is needed.
|
|
58
|
+
* Example: { message: "Email verification sent" }
|
|
59
|
+
*/
|
|
30
60
|
export interface MessageResponseDto {
|
|
31
61
|
message: string;
|
|
32
62
|
}
|
|
33
63
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
64
|
+
* ActionResponseDto
|
|
65
|
+
* Use when the client needs a machine-readable signal of what happened,
|
|
66
|
+
* e.g. for polling loops, event-driven UIs, or audit trails.
|
|
36
67
|
*
|
|
37
|
-
* action
|
|
38
|
-
* message
|
|
68
|
+
* action — stable machine-readable identifier, e.g. "account.suspended"
|
|
69
|
+
* message — human-readable description for display or logging
|
|
70
|
+
*
|
|
71
|
+
* Boolean-only DTOs (LoggedOutResponseDto, SuspendedResponseDto, etc.)
|
|
72
|
+
* are intentionally collapsed into this shape — a boolean that is always
|
|
73
|
+
* true on a 200 response carries no additional information.
|
|
39
74
|
*/
|
|
40
75
|
export interface ActionResponseDto {
|
|
41
76
|
action: string;
|
|
@@ -49,7 +84,42 @@ export interface PaginationMeta {
|
|
|
49
84
|
hasNext: boolean;
|
|
50
85
|
hasPrev: boolean;
|
|
51
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* PaginatedResponseDto
|
|
89
|
+
* Wrap paginated list results in this before passing to success().
|
|
90
|
+
*
|
|
91
|
+
* Usage:
|
|
92
|
+
* success<PaginatedResponseDto<CloudAccountDto>>(res, req, {
|
|
93
|
+
* items: accounts,
|
|
94
|
+
* pagination: { page, pageSize, totalItems, totalPages, hasNext, hasPrev },
|
|
95
|
+
* });
|
|
96
|
+
*/
|
|
52
97
|
export interface PaginatedResponseDto<T> {
|
|
53
98
|
items: T[];
|
|
54
99
|
pagination: PaginationMeta;
|
|
55
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* LoginResultDto
|
|
103
|
+
* Internal auth service response returned after successful login.
|
|
104
|
+
*
|
|
105
|
+
* The optional cookie metadata allows controllers or gateways
|
|
106
|
+
* to apply refresh-token cookies without coupling shared DTOs
|
|
107
|
+
* to a specific HTTP framework.
|
|
108
|
+
*/
|
|
109
|
+
export interface CookieMetadataDto {
|
|
110
|
+
httpOnly?: boolean;
|
|
111
|
+
secure?: boolean;
|
|
112
|
+
sameSite?: "strict" | "lax" | "none";
|
|
113
|
+
maxAge?: number;
|
|
114
|
+
path?: string;
|
|
115
|
+
domain?: string;
|
|
116
|
+
}
|
|
117
|
+
export interface LoginResultCookieDto {
|
|
118
|
+
name: string;
|
|
119
|
+
value: string;
|
|
120
|
+
options: CookieMetadataDto;
|
|
121
|
+
}
|
|
122
|
+
export interface LoginResultDto {
|
|
123
|
+
tokens: TokensResponseDto;
|
|
124
|
+
cookie?: LoginResultCookieDto;
|
|
125
|
+
}
|