@oxy.so/core 7.16.0 → 7.17.0
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/README.md +24 -13
- package/dist/cjs/server/OxyServer.js +85 -3
- package/dist/cjs/server/appPermissions.js +14 -92
- package/dist/cjs/server/callerAccess.js +151 -0
- package/dist/cjs/server/callerAuthority.js +206 -0
- package/dist/cjs/server/capabilityTicket.js +39 -6
- package/dist/cjs/server/cors.js +41 -2
- package/dist/cjs/server/index.js +33 -5
- package/dist/cjs/server/liveCapabilityTicket.js +4 -2
- package/dist/cjs/server/middleware.js +104 -2
- package/dist/cjs/server/namespaces.js +15 -4
- package/dist/cjs/server/safeFetch.js +29 -1
- package/dist/cjs/server/userClient.js +127 -0
- package/dist/cjs/utils/cache.js +58 -22
- package/dist/esm/server/OxyServer.js +85 -3
- package/dist/esm/server/appPermissions.js +12 -89
- package/dist/esm/server/callerAccess.js +145 -0
- package/dist/esm/server/callerAuthority.js +194 -0
- package/dist/esm/server/capabilityTicket.js +38 -6
- package/dist/esm/server/cors.js +39 -2
- package/dist/esm/server/index.js +26 -3
- package/dist/esm/server/liveCapabilityTicket.js +5 -3
- package/dist/esm/server/middleware.js +104 -2
- package/dist/esm/server/namespaces.js +15 -4
- package/dist/esm/server/safeFetch.js +29 -1
- package/dist/esm/server/userClient.js +121 -0
- package/dist/esm/utils/cache.js +57 -22
- package/dist/types/api/accounts.d.ts +8 -1
- package/dist/types/api/apps.d.ts +22 -4
- package/dist/types/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +74 -7
- package/dist/types/server/appPermissions.d.ts +3 -13
- package/dist/types/server/auth.d.ts +5 -0
- package/dist/types/server/callerAccess.d.ts +86 -0
- package/dist/types/server/callerAuthority.d.ts +127 -0
- package/dist/types/server/capabilityTicket.d.ts +23 -1
- package/dist/types/server/cors.d.ts +39 -0
- package/dist/types/server/index.d.ts +31 -7
- package/dist/types/server/liveCapabilityTicket.d.ts +6 -2
- package/dist/types/server/middleware.d.ts +34 -2
- package/dist/types/server/namespaces.d.ts +18 -2
- package/dist/types/server/safeFetch.d.ts +10 -0
- package/dist/types/server/userClient.d.ts +71 -0
- package/dist/types/utils/cache.d.ts +13 -0
- package/package.json +1 -1
- package/src/api/accounts.ts +8 -1
- package/src/api/apps.ts +23 -4
- package/src/index.ts +1 -0
- package/src/server/OxyServer.ts +126 -7
- package/src/server/appPermissions.ts +28 -105
- package/src/server/auth.ts +5 -0
- package/src/server/callerAccess.ts +232 -0
- package/src/server/callerAuthority.ts +267 -0
- package/src/server/capabilityTicket.ts +76 -17
- package/src/server/cors.ts +63 -2
- package/src/server/index.ts +38 -4
- package/src/server/liveCapabilityTicket.ts +12 -5
- package/src/server/middleware.ts +171 -4
- package/src/server/namespaces.ts +36 -5
- package/src/server/safeFetch.ts +31 -1
- package/src/server/userClient.ts +147 -0
- package/src/utils/cache.ts +56 -18
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The package exposes two public entry points:
|
|
|
31
31
|
- `@oxy.so/core/session` — `SessionClient`, the account dialog controller, auth-state stores, cold boot
|
|
32
32
|
- `@oxy.so/core/crypto` — `KeyManager`, `SignatureService`, `RecoveryPhraseService` and the primitives under them
|
|
33
33
|
- `@oxy.so/core/civic`, `@oxy.so/core/inference`, `@oxy.so/core/logger`
|
|
34
|
-
- `@oxy.so/core/server` — `OxyServer` (service tokens, `middleware
|
|
34
|
+
- `@oxy.so/core/server` — `OxyServer` (workload service tokens, `middleware.*`, `withUser` / `asUser`, `appPermissionsFor`, `accountAccessFor`, `applicationAccessFor`) and the Express helpers (`createOxyRateLimit`, `createOxyAuthMiddleware`, `getOxyUserId`, `getRequiredOxyUserId`, `createOxyCors`, `createOxySocketCors`, `createOxySecurityHeaders`, `buildOxyCspDirectives`, `safeFetch`, `verifySecret`, and request types)
|
|
35
35
|
|
|
36
36
|
Heavy pieces have their own entries so an app ships only what it uses; React Native and browser bundles never import Express. The full API map is [docs/api.mdx](./docs/api.mdx).
|
|
37
37
|
|
|
@@ -136,34 +136,45 @@ Caching is left to the consumer's own layer (React Query / stores). Pass
|
|
|
136
136
|
`oxy.createLinkedClient({ baseURL, enableCache: true })` to opt back in when the
|
|
137
137
|
consumer accepts responsibility for invalidation.
|
|
138
138
|
|
|
139
|
-
##
|
|
139
|
+
## Backends
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
A backend uses ONE `OxyServer` per process. An Oxy-owned backend authenticates
|
|
142
|
+
as its workload (ADR 0026/0049): no api key, no client secret.
|
|
143
143
|
|
|
144
144
|
```ts
|
|
145
|
-
import { OxyServices } from '@oxy.so/core';
|
|
146
145
|
import {
|
|
146
|
+
OxyServer,
|
|
147
|
+
createOxyAuthMiddleware,
|
|
147
148
|
createOxyRateLimit,
|
|
148
|
-
requireOxyAuth,
|
|
149
149
|
getRequiredOxyUserId,
|
|
150
150
|
type OxyAuthRequest,
|
|
151
151
|
} from '@oxy.so/core/server';
|
|
152
152
|
|
|
153
|
-
const oxy = new
|
|
153
|
+
export const oxy = new OxyServer({
|
|
154
|
+
baseURL: 'https://api.oxy.so',
|
|
155
|
+
applicationId: process.env.OXY_APPLICATION_ID,
|
|
156
|
+
serviceIdentity: 'when-anonymous',
|
|
157
|
+
});
|
|
154
158
|
|
|
155
159
|
app.use(createOxyRateLimit(oxy, { store: redisStore }));
|
|
156
|
-
|
|
160
|
+
app.use('/api', createOxyAuthMiddleware(oxy));
|
|
157
161
|
|
|
158
162
|
router.get('/me', (req: OxyAuthRequest, res) => {
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
res.json({ userId: getRequiredOxyUserId(req) });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// As the person behind the request — never on the shared client:
|
|
167
|
+
router.get('/orgs/:accountId', async (req, res) => {
|
|
168
|
+
const access = await oxy.accountAccessFor(req, req.params.accountId);
|
|
169
|
+
res.json({ canEdit: access.has('account:update') });
|
|
161
170
|
});
|
|
162
171
|
```
|
|
163
172
|
|
|
164
|
-
|
|
165
|
-
`
|
|
166
|
-
|
|
173
|
+
`createOxyAuthMiddleware(oxy)` resolves the bearer session and requires a user
|
|
174
|
+
in one middleware; `createOptionalOxyAuth(oxy)` is the optional form. The
|
|
175
|
+
permission questions (`appPermissionsFor`, `accountAccessFor`,
|
|
176
|
+
`applicationAccessFor`), user clients (`withUser`), files and outbound HTTP are
|
|
177
|
+
in [Building a backend](./docs/backend.mdx).
|
|
167
178
|
|
|
168
179
|
## Security Headers And CSP
|
|
169
180
|
|
|
@@ -55,6 +55,8 @@ const workloadIdentity_1 = require("./workloadIdentity");
|
|
|
55
55
|
const middleware_1 = require("./middleware");
|
|
56
56
|
const appPermissions_1 = require("./appPermissions");
|
|
57
57
|
const namespaces_1 = require("./namespaces");
|
|
58
|
+
const callerAccess_1 = require("./callerAccess");
|
|
59
|
+
const userClient_1 = require("./userClient");
|
|
58
60
|
const safety_1 = require("./safety");
|
|
59
61
|
const session_1 = require("./session");
|
|
60
62
|
const serviceEvents_1 = require("./serviceEvents");
|
|
@@ -170,15 +172,24 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
170
172
|
defaultApplicationId: () => this.defaultApplicationId(),
|
|
171
173
|
fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
|
|
172
174
|
});
|
|
175
|
+
this.callerAccess = new callerAccess_1.OxyCallerAccessResolver({
|
|
176
|
+
ttlMs: ttl,
|
|
177
|
+
readAccount: (accountId, accessToken) => this.withUser(accessToken, (user) => user.accounts.get(accountId)),
|
|
178
|
+
readApplication: (applicationId, accessToken) => this.withUser(accessToken, (user) => user.apps.get(applicationId)),
|
|
179
|
+
});
|
|
180
|
+
const defaultAudience = config.expectedAudience?.trim() || undefined;
|
|
173
181
|
this.middleware = (0, middleware_1.createOxyMiddleware)({
|
|
174
182
|
get baseURL() {
|
|
175
183
|
return server.baseURL;
|
|
176
184
|
},
|
|
185
|
+
defaultAudience,
|
|
177
186
|
jwksCache: this.jwksCache,
|
|
178
187
|
validateSession: (sessionId, options) => this.session.validate(sessionId, options),
|
|
179
188
|
verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
|
|
180
189
|
introspectServiceToken: (token) => this.introspectServiceToken(token),
|
|
181
190
|
appPermissionsFor: (req, options) => this.appPermissionsFor(req, options),
|
|
191
|
+
accountAccessFor: (req, accountId, options) => this.accountAccessFor(req, accountId, options),
|
|
192
|
+
applicationAccessFor: (req, applicationId, options) => this.applicationAccessFor(req, applicationId, options),
|
|
182
193
|
});
|
|
183
194
|
}
|
|
184
195
|
/** A product SDK transport authenticated only by this process's workload. */
|
|
@@ -605,12 +616,83 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
605
616
|
return this.appPermissions.resolve(req, options);
|
|
606
617
|
}
|
|
607
618
|
/**
|
|
608
|
-
* Drop cached
|
|
609
|
-
*
|
|
610
|
-
*
|
|
619
|
+
* Drop cached caller-authority answers — application permissions and
|
|
620
|
+
* account / application access — all of them, or one person's (as the
|
|
621
|
+
* session user or the resolved operator). For a backend that learns of a
|
|
622
|
+
* revocation out of band and will not wait out the TTL.
|
|
611
623
|
*/
|
|
612
624
|
invalidateAppPermissions(filter = {}) {
|
|
613
625
|
this.appPermissions.invalidate(filter);
|
|
626
|
+
this.callerAccess.invalidate(filter);
|
|
627
|
+
}
|
|
628
|
+
// ── The requesting person ────────────────────────────────────────────────
|
|
629
|
+
/**
|
|
630
|
+
* An isolated Oxy client authenticated as the person behind `source` — a
|
|
631
|
+
* request `middleware.auth()` resolved to a user session, or a bearer you
|
|
632
|
+
* already verified. Every `OxyServices` namespace, with no response cache,
|
|
633
|
+
* no token refresh and no fallback to this server's service identity; this
|
|
634
|
+
* server's own session is never touched.
|
|
635
|
+
*
|
|
636
|
+
* Prefer {@link withUser}, which disposes it for you. A client from here
|
|
637
|
+
* should be disposed when the request ends (`client.dispose()`) so a stray
|
|
638
|
+
* reference cannot keep using the bearer; one that is not holds no global
|
|
639
|
+
* resource and is collected normally.
|
|
640
|
+
*
|
|
641
|
+
* Throws `OxyAccessError` (`ACCESS_SESSION_REQUIRED`) for a request with no
|
|
642
|
+
* user session, including one authenticated by a service token.
|
|
643
|
+
*/
|
|
644
|
+
asUser(source) {
|
|
645
|
+
const config = (0, userClient_1.userClientConfig)(this.config);
|
|
646
|
+
return new userClient_1.OxyUserClient({ ...config, baseURL: this.baseURL, cloudURL: this.cloudURL }, (0, userClient_1.userBearerOf)(source));
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Run `use` with a client authenticated as the person behind `source`
|
|
650
|
+
* ({@link asUser}), and dispose it when `use` settles — success or failure.
|
|
651
|
+
*
|
|
652
|
+
* ```ts
|
|
653
|
+
* const accounts = await oxy.withUser(req, (user) => user.accounts.list());
|
|
654
|
+
* ```
|
|
655
|
+
*/
|
|
656
|
+
async withUser(source, use) {
|
|
657
|
+
const user = this.asUser(source);
|
|
658
|
+
try {
|
|
659
|
+
return await use(user);
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
user.dispose();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* What the person behind `req` may do in Oxy account `accountId` — an
|
|
667
|
+
* organization, store, channel or their own personal account: relationship,
|
|
668
|
+
* role, source and the EFFECTIVE permissions (inheritance, grants and
|
|
669
|
+
* revokes applied). Read with the request's own verified bearer.
|
|
670
|
+
*
|
|
671
|
+
* Cached per session for `appPermissionsCacheTtlMs` (30 s), positive
|
|
672
|
+
* answers only; pass `{ fresh: true }` before a write. Throws
|
|
673
|
+
* `OxyAccessError`: `ACCESS_SESSION_REQUIRED` (no session, a service token,
|
|
674
|
+
* or Oxy answered 401), `ACCESS_DENIED` (Oxy answered 403/404 — no access,
|
|
675
|
+
* or no such account), `ACCESS_UNAVAILABLE` (anything else; never a "no").
|
|
676
|
+
* Map it with `accessErrorStatus`. Gate on `has(permission)`, never on
|
|
677
|
+
* `role`.
|
|
678
|
+
*/
|
|
679
|
+
accountAccessFor(req, accountId, options = {}) {
|
|
680
|
+
return this.callerAccess.account(req, accountId, options);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* What the person behind `req` may do to Oxy application `applicationId`,
|
|
684
|
+
* through their membership of the account that owns it (developer RBAC:
|
|
685
|
+
* `app:read`, `app:update`, `webhooks:update`, `billing:manage`, …), plus
|
|
686
|
+
* its `ownerAccountId` and whether it is `official`. Read with the
|
|
687
|
+
* request's own verified bearer.
|
|
688
|
+
*
|
|
689
|
+
* Not {@link appPermissionsFor}: that one answers which of THIS backend's
|
|
690
|
+
* own declared operator keys the person holds. Same cache, options and
|
|
691
|
+
* errors as {@link accountAccessFor}; Oxy refuses (403) a caller with no
|
|
692
|
+
* `app:read`, so a successful answer always includes it.
|
|
693
|
+
*/
|
|
694
|
+
applicationAccessFor(req, applicationId, options = {}) {
|
|
695
|
+
return this.callerAccess.application(req, applicationId, options);
|
|
614
696
|
}
|
|
615
697
|
/**
|
|
616
698
|
* Drop cached session validations — all of them, one user's or one
|
|
@@ -45,16 +45,14 @@
|
|
|
45
45
|
* Node only; reachable solely from `@oxy.so/core/server`.
|
|
46
46
|
*/
|
|
47
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
-
exports.OxyAppPermissionsResolver = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = void 0;
|
|
48
|
+
exports.OxyAppPermissionsResolver = exports.appPermissionErrorBody = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = void 0;
|
|
49
49
|
exports.appPermissionErrorStatus = appPermissionErrorStatus;
|
|
50
|
-
exports.appPermissionErrorBody = appPermissionErrorBody;
|
|
51
50
|
exports.assertApplicationPermissionKey = assertApplicationPermissionKey;
|
|
52
51
|
const contracts_1 = require("@oxy.so/contracts");
|
|
53
52
|
const logger_1 = require("../logger");
|
|
53
|
+
const callerAuthority_1 = require("./callerAuthority");
|
|
54
54
|
/** Default cache lifetime of one answer. */
|
|
55
55
|
exports.APP_PERMISSIONS_CACHE_TTL_MS = 30000;
|
|
56
|
-
/** Entries kept per server; the oldest goes first. */
|
|
57
|
-
const APP_PERMISSIONS_CACHE_MAX = 1000;
|
|
58
56
|
/** The typed failure of {@link OxyAppPermissionsResolver.resolve} and the middleware. */
|
|
59
57
|
class OxyAppPermissionError extends Error {
|
|
60
58
|
constructor(code, message, options = {}) {
|
|
@@ -80,20 +78,7 @@ function appPermissionErrorStatus(error, deny = 404) {
|
|
|
80
78
|
}
|
|
81
79
|
}
|
|
82
80
|
/** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
|
|
83
|
-
|
|
84
|
-
switch (status) {
|
|
85
|
-
case 401:
|
|
86
|
-
return { error: { code: 'unauthorized', message: 'Authentication required' } };
|
|
87
|
-
case 403:
|
|
88
|
-
return { error: { code: 'forbidden', message: 'Not permitted' } };
|
|
89
|
-
case 404:
|
|
90
|
-
return { error: { code: 'not_found', message: 'Not found' } };
|
|
91
|
-
case 503:
|
|
92
|
-
return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
|
|
93
|
-
default:
|
|
94
|
-
return { error: { code: 'internal_error', message: 'Internal error' } };
|
|
95
|
-
}
|
|
96
|
-
}
|
|
81
|
+
exports.appPermissionErrorBody = callerAuthority_1.authorityErrorBody;
|
|
97
82
|
/** Throws at MOUNT time on a key that could never be held — a typo is a deploy error. */
|
|
98
83
|
function assertApplicationPermissionKey(permission) {
|
|
99
84
|
if (!(0, contracts_1.isApplicationPermissionKey)(permission)) {
|
|
@@ -101,18 +86,7 @@ function assertApplicationPermissionKey(permission) {
|
|
|
101
86
|
}
|
|
102
87
|
}
|
|
103
88
|
function heldPermissions(applicationId, operatorId, permissions) {
|
|
104
|
-
|
|
105
|
-
const set = new Set(sorted);
|
|
106
|
-
return Object.freeze({
|
|
107
|
-
applicationId,
|
|
108
|
-
operatorId,
|
|
109
|
-
permissions: sorted,
|
|
110
|
-
has: (permission) => set.has(permission),
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
function errorStatus(error) {
|
|
114
|
-
const status = error?.status;
|
|
115
|
-
return typeof status === 'number' ? status : undefined;
|
|
89
|
+
return Object.freeze({ applicationId, operatorId, ...(0, callerAuthority_1.permissionSet)(permissions) });
|
|
116
90
|
}
|
|
117
91
|
/**
|
|
118
92
|
* The cache and the lookup behind `OxyServer.appPermissionsFor` and
|
|
@@ -121,22 +95,18 @@ function errorStatus(error) {
|
|
|
121
95
|
class OxyAppPermissionsResolver {
|
|
122
96
|
constructor(host) {
|
|
123
97
|
this.host = host;
|
|
124
|
-
this.cache = new
|
|
125
|
-
this.pending = new Map();
|
|
98
|
+
this.cache = new callerAuthority_1.CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
|
|
126
99
|
}
|
|
127
100
|
/**
|
|
128
101
|
* What the person behind `req` holds. Throws {@link OxyAppPermissionError};
|
|
129
102
|
* never answers "nothing held" for a failure.
|
|
130
103
|
*/
|
|
131
104
|
async resolve(req, options = {}) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const accessToken = typeof req.accessToken === 'string' && req.accessToken.length > 0 ? req.accessToken : null;
|
|
138
|
-
if (!userId || !sessionId || !accessToken) {
|
|
139
|
-
throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'No verified user session on this request; mount middleware.auth() first');
|
|
105
|
+
const caller = (0, callerAuthority_1.readCallerSession)(req);
|
|
106
|
+
if (!caller.ok) {
|
|
107
|
+
throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
|
|
108
|
+
? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
|
|
109
|
+
: 'No verified user session on this request; mount middleware.auth() first');
|
|
140
110
|
}
|
|
141
111
|
let applicationId = options.applicationId ?? null;
|
|
142
112
|
if (!applicationId) {
|
|
@@ -150,63 +120,15 @@ class OxyAppPermissionsResolver {
|
|
|
150
120
|
if (!applicationId) {
|
|
151
121
|
throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
|
|
152
122
|
}
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
if (options.fresh !== true && ttl > 0) {
|
|
156
|
-
const cached = this.cache.get(key);
|
|
157
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
158
|
-
return cached.value;
|
|
159
|
-
}
|
|
160
|
-
const inFlight = this.pending.get(key);
|
|
161
|
-
if (inFlight)
|
|
162
|
-
return inFlight;
|
|
163
|
-
}
|
|
164
|
-
const startedAt = Date.now();
|
|
165
|
-
const lookup = this.lookup(applicationId, accessToken).then((value) => {
|
|
166
|
-
if (ttl > 0)
|
|
167
|
-
this.remember(key, value, startedAt, ttl);
|
|
168
|
-
return value;
|
|
169
|
-
});
|
|
170
|
-
if (options.fresh === true || ttl <= 0)
|
|
171
|
-
return lookup;
|
|
172
|
-
this.pending.set(key, lookup);
|
|
173
|
-
try {
|
|
174
|
-
return await lookup;
|
|
175
|
-
}
|
|
176
|
-
finally {
|
|
177
|
-
if (this.pending.get(key) === lookup)
|
|
178
|
-
this.pending.delete(key);
|
|
179
|
-
}
|
|
123
|
+
const appId = applicationId;
|
|
124
|
+
return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
|
|
180
125
|
}
|
|
181
126
|
/**
|
|
182
127
|
* Forget cached answers: every one, or those about one person (matched as
|
|
183
128
|
* either the session's user or the resolved operator).
|
|
184
129
|
*/
|
|
185
130
|
invalidate(filter = {}) {
|
|
186
|
-
|
|
187
|
-
this.cache.clear();
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
for (const [key, entry] of this.cache) {
|
|
191
|
-
const [, , userId] = JSON.parse(key);
|
|
192
|
-
if (userId === filter.userId || entry.value.operatorId === filter.userId) {
|
|
193
|
-
this.cache.delete(key);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
remember(key, value, startedAt, ttl) {
|
|
198
|
-
// A slower, OLDER read must not overwrite a newer one — the newer one may
|
|
199
|
-
// be the fresh read that saw a revocation.
|
|
200
|
-
const existing = this.cache.get(key);
|
|
201
|
-
if (existing && existing.startedAt > startedAt)
|
|
202
|
-
return;
|
|
203
|
-
this.cache.delete(key);
|
|
204
|
-
this.cache.set(key, { value, startedAt, expiresAt: startedAt + ttl });
|
|
205
|
-
if (this.cache.size > APP_PERMISSIONS_CACHE_MAX) {
|
|
206
|
-
const oldest = this.cache.keys().next().value;
|
|
207
|
-
if (oldest !== undefined)
|
|
208
|
-
this.cache.delete(oldest);
|
|
209
|
-
}
|
|
131
|
+
this.cache.invalidate(filter);
|
|
210
132
|
}
|
|
211
133
|
async lookup(applicationId, accessToken) {
|
|
212
134
|
let answer;
|
|
@@ -214,7 +136,7 @@ class OxyAppPermissionsResolver {
|
|
|
214
136
|
answer = await this.host.fetchRolesMe(applicationId, accessToken);
|
|
215
137
|
}
|
|
216
138
|
catch (error) {
|
|
217
|
-
const status = errorStatus(error);
|
|
139
|
+
const status = (0, callerAuthority_1.errorStatus)(error);
|
|
218
140
|
if (status === 401) {
|
|
219
141
|
throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'Oxy refused the session while resolving application permissions', { cause: error });
|
|
220
142
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The person behind a request, in an Oxy account and over an Oxy application:
|
|
4
|
+
* `OxyServer.accountAccessFor` and `OxyServer.applicationAccessFor`.
|
|
5
|
+
*
|
|
6
|
+
* Both read Oxy with the request's OWN verified bearer (an `OxyUserClient`),
|
|
7
|
+
* so Oxy resolves the HUMAN operator from the session — a person acting as
|
|
8
|
+
* their organization is answered for themselves — with inheritance, per-member
|
|
9
|
+
* grants and revokes applied. Nothing else the client sends is consulted.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* // Can the caller update this store (an Oxy account) in my app?
|
|
13
|
+
* const access = await oxy.accountAccessFor(req, storeAccountId, { fresh: true });
|
|
14
|
+
* if (!access.has('account:update')) throw new OxyAccessError('ACCESS_DENIED', '…');
|
|
15
|
+
*
|
|
16
|
+
* // Can the caller manage webhooks of application A (developer RBAC)?
|
|
17
|
+
* const app = await oxy.applicationAccessFor(req, applicationId);
|
|
18
|
+
* if (!app.has('webhooks:update')) …
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Which reader answers which question is in `./callerAuthority`; the short
|
|
22
|
+
* version: `appPermissionsFor` is about THIS backend's own declared operator
|
|
23
|
+
* keys, `applicationAccessFor` about any application through the account that
|
|
24
|
+
* owns it, `accountAccessFor` about an account. For work with no person
|
|
25
|
+
* present (a background import), `oxy.accounts.accessFor(accountId, userId)`
|
|
26
|
+
* asks with the service's own token instead.
|
|
27
|
+
*
|
|
28
|
+
* Node only; reachable solely from `@oxy.so/core/server`.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.OxyCallerAccessResolver = void 0;
|
|
32
|
+
exports.parseAccountAccess = parseAccountAccess;
|
|
33
|
+
exports.parseApplicationAccess = parseApplicationAccess;
|
|
34
|
+
const contracts_1 = require("@oxy.so/contracts");
|
|
35
|
+
const callerAuthority_1 = require("./callerAuthority");
|
|
36
|
+
function callerSession(req) {
|
|
37
|
+
const caller = (0, callerAuthority_1.readCallerSession)(req);
|
|
38
|
+
if (caller.ok)
|
|
39
|
+
return caller.session;
|
|
40
|
+
throw new callerAuthority_1.OxyAccessError('ACCESS_SESSION_REQUIRED', caller.reason === 'service'
|
|
41
|
+
? 'This request is authenticated by a service token; account and application access belong to a person in a session'
|
|
42
|
+
: 'No verified user session on this request; mount middleware.auth() first');
|
|
43
|
+
}
|
|
44
|
+
function requireId(id, what) {
|
|
45
|
+
if (typeof id !== 'string' || id.trim() === '') {
|
|
46
|
+
throw new TypeError(`${what} must be a non-empty string`);
|
|
47
|
+
}
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
function record(value) {
|
|
51
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
52
|
+
? value
|
|
53
|
+
: null;
|
|
54
|
+
}
|
|
55
|
+
function stringArray(value) {
|
|
56
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
57
|
+
? value
|
|
58
|
+
: null;
|
|
59
|
+
}
|
|
60
|
+
const RELATIONSHIPS = ['self', 'owner', 'member'];
|
|
61
|
+
/** `GET /accounts/:id` → the caller's effective access, or `null` for a malformed answer. */
|
|
62
|
+
function parseAccountAccess(accountId, answer) {
|
|
63
|
+
const node = record(answer);
|
|
64
|
+
if (!node || node.accountId !== accountId)
|
|
65
|
+
return null;
|
|
66
|
+
const relationship = node.relationship;
|
|
67
|
+
if (!RELATIONSHIPS.includes(relationship) || typeof node.kind !== 'string')
|
|
68
|
+
return null;
|
|
69
|
+
const grant = contracts_1.accountAccessGrantSchema.safeParse(node.callerAccess);
|
|
70
|
+
if (!grant.success)
|
|
71
|
+
return null;
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
accountId,
|
|
74
|
+
kind: node.kind,
|
|
75
|
+
relationship,
|
|
76
|
+
role: grant.data.role,
|
|
77
|
+
source: grant.data.source,
|
|
78
|
+
...(0, callerAuthority_1.permissionSet)(grant.data.permissions),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** `GET /applications/:id` → the caller's access over it, or `null` for a malformed answer. */
|
|
82
|
+
function parseApplicationAccess(applicationId, answer) {
|
|
83
|
+
const application = record(answer);
|
|
84
|
+
if (!application || application._id !== applicationId)
|
|
85
|
+
return null;
|
|
86
|
+
const ownerAccountId = application.ownerAccountId;
|
|
87
|
+
const membership = record(application.callerMembership);
|
|
88
|
+
const permissions = stringArray(membership?.permissions);
|
|
89
|
+
if (typeof ownerAccountId !== 'string' ||
|
|
90
|
+
ownerAccountId === '' ||
|
|
91
|
+
typeof application.isOfficial !== 'boolean' ||
|
|
92
|
+
!membership ||
|
|
93
|
+
typeof membership.role !== 'string' ||
|
|
94
|
+
membership.role === '' ||
|
|
95
|
+
membership.ownerAccountId !== ownerAccountId ||
|
|
96
|
+
!permissions) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return Object.freeze({
|
|
100
|
+
applicationId,
|
|
101
|
+
ownerAccountId,
|
|
102
|
+
official: application.isOfficial,
|
|
103
|
+
role: membership.role,
|
|
104
|
+
...(0, callerAuthority_1.permissionSet)(permissions),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** The caches and lookups behind `accountAccessFor` / `applicationAccessFor`. One per server. */
|
|
108
|
+
class OxyCallerAccessResolver {
|
|
109
|
+
constructor(host) {
|
|
110
|
+
this.host = host;
|
|
111
|
+
this.accounts = new callerAuthority_1.CallerAuthorityCache(host.ttlMs);
|
|
112
|
+
this.applications = new callerAuthority_1.CallerAuthorityCache(host.ttlMs);
|
|
113
|
+
}
|
|
114
|
+
async account(req, accountId, options = {}) {
|
|
115
|
+
const id = requireId(accountId, 'accountId');
|
|
116
|
+
const session = callerSession(req);
|
|
117
|
+
return this.accounts.read([id], session, options.fresh === true, async () => {
|
|
118
|
+
const answer = await this.host
|
|
119
|
+
.readAccount(id, session.accessToken)
|
|
120
|
+
.catch((error) => {
|
|
121
|
+
throw (0, callerAuthority_1.accessFailure)(error, `account ${id}`);
|
|
122
|
+
});
|
|
123
|
+
const access = parseAccountAccess(id, answer);
|
|
124
|
+
if (!access) {
|
|
125
|
+
throw new callerAuthority_1.OxyAccessError('ACCESS_UNAVAILABLE', `Oxy answered account ${id} without the caller's access`);
|
|
126
|
+
}
|
|
127
|
+
return access;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async application(req, applicationId, options = {}) {
|
|
131
|
+
const id = requireId(applicationId, 'applicationId');
|
|
132
|
+
const session = callerSession(req);
|
|
133
|
+
return this.applications.read([id], session, options.fresh === true, async () => {
|
|
134
|
+
const answer = await this.host
|
|
135
|
+
.readApplication(id, session.accessToken)
|
|
136
|
+
.catch((error) => {
|
|
137
|
+
throw (0, callerAuthority_1.accessFailure)(error, `application ${id}`);
|
|
138
|
+
});
|
|
139
|
+
const access = parseApplicationAccess(id, answer);
|
|
140
|
+
if (!access) {
|
|
141
|
+
throw new callerAuthority_1.OxyAccessError('ACCESS_UNAVAILABLE', `Oxy answered application ${id} without the caller's access`);
|
|
142
|
+
}
|
|
143
|
+
return access;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
invalidate(filter = {}) {
|
|
147
|
+
this.accounts.invalidate(filter);
|
|
148
|
+
this.applications.invalidate(filter);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
exports.OxyCallerAccessResolver = OxyCallerAccessResolver;
|