Skip to content

Commit 9cd7b44

Browse files
feat(sdk): Add requiredObligations to the PermissionDeniedError (#781)
* add requiredObligations to the permission denied error * bump version for xtest trial, point to xtest branch * trigger again * update actual workflow * revert some of the changes made for testing * copilot suggestion * add unit test, expose error types
1 parent bb29962 commit 9cd7b44

File tree

7 files changed

+129
-5
lines changed

7 files changed

+129
-5
lines changed

lib/src/access/access-rpc.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,22 @@ export function handleRpcRewrapError(e: unknown, platformUrl: string): never {
8585
throw new NetworkError(`[${platformUrl}] [Rewrap] ${extractRpcErrorMessage(e)}`);
8686
}
8787

88-
export function handleRpcRewrapErrorString(e: string, platformUrl: string): never {
88+
export function handleRpcRewrapErrorString(
89+
e: string,
90+
platformUrl: string,
91+
requiredObligations?: string[]
92+
): never {
8993
if (e.includes(Code[Code.InvalidArgument])) {
9094
// 400 Bad Request
9195
throw new InvalidFileError(`400 for [${platformUrl}]: rewrap bad request [${e}]`);
9296
}
9397
if (e.includes(Code[Code.PermissionDenied])) {
94-
// 403 Forbidden
98+
if (requiredObligations && requiredObligations.length > 0) {
99+
throw new PermissionDeniedError(
100+
`403 for [${platformUrl}]; rewrap permission denied`,
101+
requiredObligations
102+
);
103+
}
95104
throw new PermissionDeniedError(`403 for [${platformUrl}]; rewrap permission denied`);
96105
}
97106
if (e.includes(Code[Code.Unauthenticated])) {

lib/src/errors.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ export class UnauthenticatedError extends TdfError {
103103
/** Authorization failure (403) */
104104
export class PermissionDeniedError extends TdfError {
105105
override name = 'PermissionDeniedError';
106+
readonly requiredObligations?: string[];
107+
108+
constructor(message: string, obligations?: string[], cause?: Error) {
109+
super(message, cause);
110+
if (obligations && obligations.length > 0) {
111+
this.requiredObligations = obligations;
112+
}
113+
}
106114
}
107115

108116
/**

lib/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,15 @@ export { attributeFQNsAsValues } from './policy/api.js';
44
export { version, clientType, tdfSpecVersion } from './version.js';
55
export { PlatformClient, type PlatformClientOptions, type PlatformServices } from './platform.js';
66
export * from './opentdf.js';
7+
export {
8+
TdfError,
9+
PermissionDeniedError,
10+
IntegrityError,
11+
InvalidFileError,
12+
DecryptError,
13+
NetworkError,
14+
AttributeValidationError,
15+
ConfigurationError,
16+
} from './errors.js';
717
export * from './seekable.js';
818
export * from '../tdf3/src/models/index.js';

lib/src/nanotdf/Client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,8 @@ export default class Client {
319319
throw new DecryptError('KAS rewrap response missing expected response or result');
320320
}
321321

322+
const requiredObligations = getRequiredObligationFQNs(rewrapResp);
323+
322324
let entityWrappedKey: Uint8Array<ArrayBufferLike>;
323325
switch (result.result.case) {
324326
case 'kasWrappedKey': {
@@ -328,7 +330,8 @@ export default class Client {
328330
case 'error': {
329331
handleRpcRewrapErrorString(
330332
result.result.value,
331-
getPlatformUrlFromKasEndpoint(kasRewrapUrl)
333+
getPlatformUrlFromKasEndpoint(kasRewrapUrl),
334+
requiredObligations
332335
);
333336
}
334337
default: {
@@ -415,7 +418,7 @@ export default class Client {
415418
}
416419

417420
return {
418-
requiredObligations: getRequiredObligationFQNs(rewrapResp),
421+
requiredObligations,
419422
unwrappedKey: unwrappedKey,
420423
};
421424
}

lib/tdf3/src/tdf.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,11 @@ async function unwrapKey({
894894
}
895895

896896
case 'error': {
897-
handleRpcRewrapErrorString(result.result.value, getPlatformUrlFromKasEndpoint(url));
897+
handleRpcRewrapErrorString(
898+
result.result.value,
899+
getPlatformUrlFromKasEndpoint(url),
900+
requiredObligations
901+
);
898902
}
899903

900904
default: {

lib/tests/server.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const kas: RequestListener = async (req, res) => {
6969
'range',
7070
'x-test-response',
7171
'x-test-response-message',
72+
'x-test-required-obligations',
7273
'roundtrip-test-response',
7374
'connect-protocol-version',
7475
'connect-streaming-protocol-version',
@@ -127,6 +128,52 @@ const kas: RequestListener = async (req, res) => {
127128
res.end(JSON.stringify({ error: 'Unauthorized' }));
128129
return;
129130
case 403:
131+
if (req.headers['x-test-required-obligations']) {
132+
console.log(
133+
'[DEBUG] required obligations header found ',
134+
req.headers['x-test-required-obligations'] as string
135+
);
136+
const obligations: string[] = JSON.parse(
137+
req.headers['x-test-required-obligations'] as string
138+
);
139+
console.log('[DEBUG] required obligations: ', obligations);
140+
const reply = create(RewrapResponseSchema, {
141+
responses: [
142+
create(PolicyRewrapResultSchema, {
143+
results: [
144+
create(KeyAccessRewrapResultSchema, {
145+
metadata: {
146+
'X-Required-Obligations': {
147+
kind: {
148+
case: 'listValue',
149+
value: {
150+
values: obligations.map((obligation) =>
151+
create(ValueSchema, {
152+
kind: {
153+
case: 'stringValue',
154+
value: obligation,
155+
},
156+
})
157+
),
158+
},
159+
},
160+
},
161+
},
162+
result: {
163+
case: 'error',
164+
value: 'Permission denied',
165+
},
166+
}),
167+
],
168+
}),
169+
],
170+
});
171+
172+
res.statusCode = 200;
173+
res.setHeader('Content-Type', 'application/json');
174+
res.end(toJsonString(RewrapResponseSchema, reply));
175+
return;
176+
}
130177
res.end(JSON.stringify({ error: 'Forbidden' }));
131178
return;
132179
case 500:

lib/tests/web/platform-rpc.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { expect } from '@esm-bundle/chai';
22
import { type AuthProvider, HttpRequest, withHeaders } from '../../src/auth/auth.js';
33
import { PlatformClient } from '../../src/platform.js';
44
import { attributeFQNsAsValues } from '../../src/policy/api.js';
5+
import { fetchWrappedKey } from '../../src/access/access-rpc.js';
6+
import { PermissionDeniedError } from '../../src/errors.js';
57

68
const authProvider = <AuthProvider>{
79
updateClientPublicKey: async () => {
@@ -14,6 +16,20 @@ const authProvider = <AuthProvider>{
1416
}),
1517
};
1618

19+
function authProviderWithHeaders(headers: Record<string, string>): AuthProvider {
20+
return <AuthProvider>{
21+
updateClientPublicKey: async () => {
22+
/* mocked function */
23+
},
24+
withCreds: async (req: HttpRequest): Promise<HttpRequest> =>
25+
withHeaders(req, {
26+
Authorization:
27+
'Bearer dummy-auth-token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZGYiLCJzdWIiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.XFu4sQxAd6n-b7urqTdQ-I9zKqKSQtC04unHsMSpJjc',
28+
...headers,
29+
}),
30+
};
31+
}
32+
1733
const platformUrl = 'http://localhost:3000';
1834

1935
describe('Local Platform Connect RPC Client Tests', () => {
@@ -107,4 +123,31 @@ describe('Local Platform Connect RPC Client Tests', () => {
107123
expect.fail('Test failed missing auth headers', e);
108124
}
109125
});
126+
127+
it(`rewrap key with requiredobligations in error`, async () => {
128+
try {
129+
const ap = authProviderWithHeaders({
130+
'x-test-response': '403',
131+
'x-test-required-obligations':
132+
'["https://example.com/obl/obligation1","https://example.com/obl/obligation2"]',
133+
});
134+
try {
135+
await fetchWrappedKey(
136+
`${platformUrl}/`,
137+
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJyZXF1ZXN0Qm9keSI6Im1vY2stcmVxdWVzdC1ib2R5In0.0O9eyg-zC5Ztf78mPaa61n6INtpTdJv6iQQ_3tg2TRlzA73Md-JDTedGKwQ_J6QQycR5AMY5UqrsQvkcK50jfQ',
138+
ap
139+
);
140+
expect.fail('Test should have thrown PermissionDeniedError');
141+
} catch (e) {
142+
expect(e).to.be.instanceOf(PermissionDeniedError);
143+
const pde = e as PermissionDeniedError;
144+
expect(pde.requiredObligations).to.deep.equal([
145+
'https://example.com/obl/obligation1',
146+
'https://example.com/obl/obligation2',
147+
]);
148+
}
149+
} catch (e) {
150+
console.log(e);
151+
}
152+
});
110153
});

0 commit comments

Comments
 (0)