Skip to content

Commit 48b2442

Browse files
feat(lib): Load abac config from policy service (#351)
New parameters for tdf/client allow looking up attribute definitions and KAS grants to autoconfigure with just attribute URLs. - New arguments to cli `encrypt` - `encrypt --autoconfigure` enables attribute lookup during encrypt and corresponding updates to the KAO - `--policyEndpoint` allows KAS and policy service to be hosted separately. if not set, it is inferred by removing `/kas` off of the end of the `--kasEndpoint` argument. - New cli command, `attrs`, which prints out the JSON hydrated version of the attributes from the policy service
1 parent 6eb70c1 commit 48b2442

File tree

13 files changed

+671
-3392
lines changed

13 files changed

+671
-3392
lines changed

.github/workflows/roundtrip/package-lock.json

Lines changed: 391 additions & 3364 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/cli.ts

Lines changed: 103 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@opentdf/client';
1919
import { CLIError, Level, log } from './logger.js';
2020
import { webcrypto } from 'crypto';
21+
import { attributeFQNsAsValues } from '@opentdf/client/nano';
2122

2223
type AuthToProcess = {
2324
auth?: string;
@@ -91,6 +92,13 @@ async function processAuth({
9192
};
9293
}
9394

95+
const rstrip = (str: string, suffix = ' '): string => {
96+
while (str && suffix && str.endsWith(suffix)) {
97+
str = str.slice(0, -suffix.length);
98+
}
99+
return str;
100+
};
101+
94102
type AnyNanoClient = NanoTDFClient | NanoTDFDatasetClient;
95103

96104
function addParams(client: AnyNanoClient, argv: Partial<mainArgs>) {
@@ -120,6 +128,9 @@ async function tdf3EncryptParamsFor(argv: Partial<mainArgs>): Promise<EncryptPar
120128
if (argv.mimeType?.length) {
121129
c.setMimeType(argv.mimeType);
122130
}
131+
if (argv.autoconfigure) {
132+
c.withAutoconfigure();
133+
}
123134
// use offline mode, we do not have upsert for v2
124135
c.setOffline();
125136
// FIXME TODO must call file.close() after we are done
@@ -171,53 +182,66 @@ export const handleArgs = (args: string[]) => {
171182
// AUTH OPTIONS
172183
.option('kasEndpoint', {
173184
demandOption: true,
174-
group: 'KAS Configuration',
185+
group: 'Server Endpoints:',
175186
type: 'string',
176187
description: 'URL to non-default KAS instance (https://mykas.net)',
177188
})
178189
.option('oidcEndpoint', {
179190
demandOption: true,
180-
group: 'OIDC IdP Endpoint:',
191+
group: 'Server Endpoints:',
181192
type: 'string',
182193
description: 'URL to non-default OIDC IdP (https://myidp.net)',
183194
})
195+
.option('policyEndpoint', {
196+
group: 'Server Endpoints:',
197+
type: 'string',
198+
description: 'Attribute and key grant service endpoint',
199+
})
184200
.option('allowList', {
185-
group: 'KAS Configuration',
201+
group: 'Security:',
186202
desc: 'allowed KAS origins, comma separated; defaults to [kasEndpoint]',
187203
type: 'string',
188204
validate: (attributes: string) => attributes.split(','),
189205
})
190-
.boolean('ignoreAllowList')
206+
.option('ignoreAllowList', {
207+
group: 'Security:',
208+
desc: 'disable KAS allowlist feature for decrypt',
209+
type: 'boolean',
210+
})
191211
.option('auth', {
192-
group: 'Authentication:',
212+
group: 'OAuth and OIDC:',
193213
type: 'string',
194-
description: 'Authentication string (<clientId>:<clientSecret>)',
214+
description: 'Combined OAuth Client Credentials (<clientId>:<clientSecret>)',
215+
})
216+
.option('dpop', {
217+
group: 'Security:',
218+
desc: 'Use DPoP for token binding',
219+
type: 'boolean',
195220
})
196-
.boolean('dpop')
197221
.implies('auth', '--no-clientId')
198222
.implies('auth', '--no-clientSecret')
199223

200224
.option('clientId', {
201-
group: 'OIDC client credentials',
225+
group: 'OAuth and OIDC:',
202226
alias: 'cid',
203227
type: 'string',
204-
description: 'IdP-issued Client ID',
228+
description: 'OAuth Client Credentials: IdP-issued Client ID',
205229
})
206230
.implies('clientId', 'clientSecret')
207231

208232
.option('clientSecret', {
209-
group: 'OIDC client credentials',
233+
group: 'OAuth and OIDC:',
210234
alias: 'cs',
211235
type: 'string',
212-
description: 'IdP-issued Client Secret',
236+
description: 'OAuth Client Credentials: IdP-issued Client Secret',
213237
})
214238
.implies('clientSecret', 'clientId')
215239

216240
.option('exchangeToken', {
217-
group: 'Token from trusted external IdP to exchange for Virtru auth',
241+
group: 'OAuth and OIDC:',
218242
alias: 'et',
219243
type: 'string',
220-
description: 'Token issued by trusted external IdP',
244+
description: 'OAuth Token Exchange: Token issued by trusted external IdP',
221245
})
222246
.implies('exchangeToken', 'clientId')
223247

@@ -229,39 +253,45 @@ export const handleArgs = (args: string[]) => {
229253
// Policy, encryption, and container options
230254
.options({
231255
attributes: {
232-
group: 'Encrypt Options',
256+
group: 'Encrypt Options:',
233257
desc: 'Data attributes for the policy',
234258
type: 'string',
235259
default: '',
236260
validate: (attributes: string) => attributes.split(','),
237261
},
262+
autoconfigure: {
263+
group: 'Encrypt Options:',
264+
desc: 'Enable automatic configuration from attributes using policy service',
265+
type: 'boolean',
266+
default: false,
267+
},
238268
containerType: {
239-
group: 'Encrypt Options',
269+
group: 'Encrypt Options:',
240270
alias: 't',
241271
choices: containerTypes,
242272
description: 'Container format',
243273
default: 'nano',
244274
},
245275
policyBinding: {
246-
group: 'Encrypt Options',
276+
group: 'Encrypt Options:',
247277
choices: bindingTypes,
248278
description: 'Policy Binding Type (nano only)',
249279
default: 'gmac',
250280
},
251281
mimeType: {
252-
group: 'Encrypt Options',
282+
group: 'Encrypt Options:',
253283
desc: 'Mime type for the plain text file (only supported for ztdf)',
254284
type: 'string',
255285
default: '',
256286
},
257287
userId: {
258-
group: 'Encrypt Options',
288+
group: 'Encrypt Options:',
259289
type: 'string',
260290
description: 'Owner email address',
261291
},
262292
usersWithAccess: {
263293
alias: 'users-with-access',
264-
group: 'Encrypt Options',
294+
group: 'Encrypt Options:',
265295
desc: 'Add users to the policy',
266296
type: 'string',
267297
default: '',
@@ -272,12 +302,14 @@ export const handleArgs = (args: string[]) => {
272302
// COMMANDS
273303
.options({
274304
logLevel: {
305+
group: 'Verbosity:',
275306
alias: 'log-level',
276307
type: 'string',
277308
default: 'info',
278309
desc: 'Set logging level',
279310
},
280311
silent: {
312+
group: 'Verbosity:',
281313
type: 'boolean',
282314
default: false,
283315
desc: 'Disable logging',
@@ -287,6 +319,39 @@ export const handleArgs = (args: string[]) => {
287319
type: 'string',
288320
description: 'output file',
289321
})
322+
323+
.command(
324+
'attrs',
325+
'Look up defintions of attributes',
326+
(yargs) => {
327+
yargs.strict();
328+
},
329+
async (argv) => {
330+
log('DEBUG', 'attribute value lookup');
331+
const authProvider = await processAuth(argv);
332+
const signingKey = await crypto.subtle.generateKey(
333+
{
334+
name: 'RSASSA-PKCS1-v1_5',
335+
hash: 'SHA-256',
336+
modulusLength: 2048,
337+
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
338+
},
339+
true,
340+
['sign', 'verify']
341+
);
342+
authProvider.updateClientPublicKey(signingKey);
343+
log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`);
344+
345+
const policyUrl: string = guessPolicyUrl(argv);
346+
const defs = await attributeFQNsAsValues(
347+
policyUrl,
348+
authProvider,
349+
...(argv.attributes as string).split(',')
350+
);
351+
console.log(JSON.stringify(defs, null, 2));
352+
}
353+
)
354+
290355
.command(
291356
'decrypt [file]',
292357
'Decrypt TDF to string',
@@ -397,11 +462,13 @@ export const handleArgs = (args: string[]) => {
397462

398463
if ('tdf3' === argv.containerType || 'ztdf' === argv.containerType) {
399464
log('DEBUG', `TDF3 Client`);
465+
const policyEndpoint: string = guessPolicyUrl(argv);
400466
const client = new TDF3Client({
401467
allowedKases,
402468
ignoreAllowList,
403469
authProvider,
404470
kasEndpoint,
471+
policyEndpoint,
405472
dpopEnabled: argv.dpop,
406473
});
407474
log('SILLY', `Initialized client ${JSON.stringify(client)}`);
@@ -480,3 +547,20 @@ handleArgs(hideBin(process.argv))
480547
.catch((err) => {
481548
console.error(err);
482549
});
550+
551+
function guessPolicyUrl({
552+
kasEndpoint,
553+
policyEndpoint,
554+
}: {
555+
kasEndpoint: string;
556+
policyEndpoint?: string;
557+
}) {
558+
let policyUrl: string;
559+
if (policyEndpoint) {
560+
policyUrl = rstrip(policyEndpoint, '/');
561+
} else {
562+
const uNoSlash = rstrip(kasEndpoint, '/');
563+
policyUrl = uNoSlash.endsWith('/kas') ? uNoSlash.slice(0, -4) : uNoSlash;
564+
}
565+
return policyUrl;
566+
}

lib/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { keyAgreement } from './nanotdf-crypto/index.js';
1212
import { TypedArray, createAttribute, Policy } from './tdf/index.js';
1313
import { fetchECKasPubKey } from './access.js';
1414
import { ClientConfig } from './nanotdf/Client.js';
15+
export { attributeFQNsAsValues } from './policy/api.js';
1516

1617
// Define the EncryptOptions type
1718
export type EncryptOptions = {

lib/src/package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/src/policy/api.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { AuthProvider } from '../auth/auth.js';
2+
import { rstrip } from '../utils.js';
3+
import { GetAttributeValuesByFqnsResponse, Value } from './attributes.js';
4+
5+
export async function attributeFQNsAsValues(
6+
kasUrl: string,
7+
authProvider: AuthProvider,
8+
...fqns: string[]
9+
): Promise<Value[]> {
10+
const avs = new URLSearchParams();
11+
for (const fqn of fqns) {
12+
avs.append('fqns', fqn);
13+
}
14+
avs.append('withValue.withKeyAccessGrants', 'true');
15+
avs.append('withValue.withAttribute.withKeyAccessGrants', 'true');
16+
const uNoSlash = rstrip(kasUrl, '/');
17+
const uNoKas = uNoSlash.endsWith('/kas') ? uNoSlash.slice(0, -4) : uNoSlash;
18+
const url = `${uNoKas}/attributes/*/fqn?${avs}`;
19+
const req = await authProvider.withCreds({
20+
url,
21+
headers: {},
22+
method: 'GET',
23+
});
24+
let response: Response;
25+
try {
26+
response = await fetch(req.url, {
27+
mode: 'cors',
28+
credentials: 'same-origin',
29+
headers: req.headers,
30+
redirect: 'follow',
31+
referrerPolicy: 'no-referrer',
32+
});
33+
34+
if (!response.ok) {
35+
throw new Error(`${req.method} ${req.url} => ${response.status} ${response.statusText}`);
36+
}
37+
} catch (e) {
38+
console.error(`network error [${req.method} ${req.url}]`, e);
39+
throw e;
40+
}
41+
42+
let resp: GetAttributeValuesByFqnsResponse;
43+
try {
44+
resp = (await response.json()) as GetAttributeValuesByFqnsResponse;
45+
} catch (e) {
46+
console.error(`response parse error [${req.method} ${req.url}]`, e);
47+
throw e;
48+
}
49+
50+
const values: Value[] = [];
51+
for (const [fqn, av] of Object.entries(resp.fqnAttributeValues)) {
52+
if (!av.value) {
53+
console.log(`Missing value definition for [${fqn}]; is this a valid attribute?`);
54+
continue;
55+
}
56+
if (av.attribute && !av.value.attribute) {
57+
av.value.attribute = av.attribute;
58+
}
59+
values.push(av.value);
60+
}
61+
return values;
62+
}

lib/src/policy/attributes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export type Attribute = {
8989
metadata?: Metadata;
9090
};
9191

92+
// This is not currently needed by the client, but may be returned.
93+
// Setting it to unknown to allow it to be ignored for now.
94+
export type SubjectMapping = unknown;
95+
9296
export type Value = {
9397
id?: string;
9498
attribute?: Attribute;
@@ -98,6 +102,16 @@ export type Value = {
98102
fqn: string;
99103
/** active by default until explicitly deactivated */
100104
active?: boolean;
105+
subjectMappings?: SubjectMapping[];
101106
/** Common metadata */
102107
metadata?: Metadata;
103108
};
109+
110+
export type AttributeAndValue = {
111+
attribute: Attribute;
112+
value: Value;
113+
};
114+
115+
export type GetAttributeValuesByFqnsResponse = {
116+
fqnAttributeValues: Record<string, AttributeAndValue>;
117+
};

lib/tdf3/package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)