Skip to content

Commit e762e0f

Browse files
authored
Changed contextId semantics to support "sub-contexts" (#684)
This paves the way to support roles defined in "sub-context". With this PR you can also query using any valid contextId, it will return you everything under that sub-context.
1 parent b4e0934 commit e762e0f

22 files changed

+983
-650
lines changed

Q_AND_A.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
# DWN Q&A
33

4-
## Message ID (`messageCid`) and Record ID (`recordId`)
4+
## Message ID (`messageCid`), Record ID (`recordId`) and Context ID (`contextId`)
55

66
- Why can't/don't we use the message ID (`messageCid`) of the initial `RecordsWrite` as the record ID?
77

@@ -18,15 +18,37 @@
1818

1919
- Why is `recordId` required in a `RecordsWrite`?
2020

21-
(Last updated: 2023/05/16)
21+
(Last updated: 2024/02/06)
2222

2323
This question should be further split into two:
2424
1. Why is `recordId` required in an initial `RecordsWrite` (create)?
2525
1. Why is `recordId` required in a subsequent `RecordsWrite` (update)?
2626

2727
The latter question is much easier to answer: an update needs to reference the record that it is updating.
2828

29-
The answer to the first-part question is more complicated: `recordId` technically is not needed in an initial `RecordsWrite`, but we chose to include it for data model consistency with subsequent `RecordsWrite`, such that we can simply return the latest message of a record as the response to `RecordsRead` and `RecordsQuery` (for the most part, we still remove `authorization`) without needing to re-inject/rehydrate `recordId` into any initial `RecordsWrite`. It is also the same reason why `contextId` is required for the initial `RecordsWrite` of a protocol-authorized record.
29+
The answer to the first-part question is more complicated: `recordId` technically is not needed in an initial `RecordsWrite`, but we chose to include it for data model consistency with subsequent `RecordsWrite`, such that we can simply return the latest message of a record as the response to `RecordsRead` and `RecordsQuery` without needing to re-inject/rehydrate `recordId` into any initial `RecordsWrite`. It is also the same reason why `contextId` is required for the initial `RecordsWrite` of a protocol-authorized record.
30+
31+
- Why is `recordId` and `contextId` outside the `descriptor`.
32+
33+
- Because of the chicken-and-egg problem: `recordId` computation requires the `descriptor` as the input, so we cannot have `recordId` itself as part of the `descriptor`. `contextId` is similar in the sense that a record's `contextId` contains its own `recordId`, so it also cannot be inside the `descriptor`.
34+
35+
(Last updated: 2024/02/07)
36+
37+
- Why do we require `contextId` for protocol-based `RecordsWrite`? Can't it be inferred from `parentId` and `protocolPath`?
38+
39+
Yes, it can be inferred. But it is required for the same reason why `recordId` is required: for both implementation and developer convenience.
40+
41+
For example: for decryption, one would need to know the `contextId` to derive the decryption key at the right context, without this information readily available, the client would need to compute this value by walking up the ancestral chain themselves.
42+
43+
An alternative viable approach is to still not require it in `RecordsWrite` message and compute it internally, and return a constructed `contextId` as additional metadata along side of the `RecordsWrite` message when handling a query. This could incur cognitive load on the developers because they will likely need to pass the `contextId` in addition to the `RecordsWrite` message around instead of just passing `RecordsWrite` message. This would also mean we need to store this constructed `contextId` in the store as metadata (not just as index) so that we can return it as part of the a query (e.g. looking up the `contextId` of the parent). While this is a bigger change, open to feedback if this is indeed the preferred approach.
44+
45+
(Last update: 2024/02/07)
46+
47+
- Why does the `contextId` include the `recordId` of the record itself? Couldn't we adopt an alternative approach where the `contextId` is a path that ends at a record's parent?
48+
49+
Yes, we could opt to exclude the `recordId` of the record from the `contextId` of the record itself. However, this would complicate the process of querying for all records of a given context when the root record itself needs to be included. For instance, if we have a root "Thread" context record and we want to retrieve all the records of this Thread, including the root Thread record, the absence of a `contextId` containing its own `recordId` would necessitate a separate or more complex query to fetch the Thread record.
50+
51+
(Last update: 2024/02/07)
3052

3153

3254
## Encryption

json-schemas/interface-methods/records-write-unidentified.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"type": "string"
1212
},
1313
"contextId": {
14-
"type": "string"
14+
"type": "string",
15+
"pattern": "^[a-zA-Z0-9]+(\/[a-zA-Z0-9]+)*$"
1516
},
1617
"attestation": {
1718
"$ref": "https://identity.foundation/dwn/json-schemas/general-jws.json"

src/core/dwn-error.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export enum DwnErrorCode {
6262
ProtocolAuthorizationDuplicateContextRoleRecipient = 'ProtocolAuthorizationDuplicateContextRoleRecipient',
6363
ProtocolAuthorizationDuplicateGlobalRoleRecipient = 'ProtocolAuthorizationDuplicateGlobalRoleRecipient',
6464
ProtocolAuthorizationIncorrectDataFormat = 'ProtocolAuthorizationIncorrectDataFormat',
65+
ProtocolAuthorizationIncorrectContextId = 'ProtocolAuthorizationIncorrectContextId',
6566
ProtocolAuthorizationIncorrectProtocolPath = 'ProtocolAuthorizationIncorrectProtocolPath',
6667
ProtocolAuthorizationInvalidSchema = 'ProtocolAuthorizationInvalidSchema',
6768
ProtocolAuthorizationInvalidType = 'ProtocolAuthorizationInvalidType',
@@ -70,7 +71,7 @@ export enum DwnErrorCode {
7071
ProtocolAuthorizationMissingRuleSet = 'ProtocolAuthorizationMissingRuleSet',
7172
ProtocolAuthorizationParentlessIncorrectProtocolPath = 'ProtocolAuthorizationParentlessIncorrectProtocolPath',
7273
ProtocolAuthorizationNotARole = 'ProtocolAuthorizationNotARole',
73-
ProtocolAuthorizationParentNotFound = 'ProtocolAuthorizationParentNotFound',
74+
ProtocolAuthorizationParentNotFoundConstructingAncestorChain = 'ProtocolAuthorizationParentNotFoundConstructingAncestorChain',
7475
ProtocolAuthorizationProtocolNotFound = 'ProtocolAuthorizationProtocolNotFound',
7576
ProtocolAuthorizationQueryWithoutRole = 'ProtocolAuthorizationQueryWithoutRole',
7677
ProtocolAuthorizationRoleMissingRecipient = 'ProtocolAuthorizationRoleMissingRecipient',
@@ -113,7 +114,6 @@ export enum DwnErrorCode {
113114
RecordsWriteAttestationIntegrityInvalidPayloadProperty = 'RecordsWriteAttestationIntegrityInvalidPayloadProperty',
114115
RecordsWriteAuthorizationFailed = 'RecordsWriteAuthorizationFailed',
115116
RecordsWriteCreateMissingSigner = 'RecordsWriteCreateMissingSigner',
116-
RecordsWriteCreateContextIdAndParentIdMutuallyInclusive = 'RecordsWriteCreateContextIdAndParentIdMutuallyInclusive',
117117
RecordsWriteCreateDataAndDataCidMutuallyExclusive = 'RecordsWriteCreateDataAndDataCidMutuallyExclusive',
118118
RecordsWriteCreateDataCidAndDataSizeMutuallyInclusive = 'RecordsWriteCreateDataCidAndDataSizeMutuallyInclusive',
119119
RecordsWriteCreateProtocolAndProtocolPathMutuallyInclusive = 'RecordsWriteCreateProtocolAndProtocolPathMutuallyInclusive',

src/core/protocol-authorization.ts

Lines changed: 74 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { RecordsSubscribe } from '../interfaces/records-subscribe.js';
77
import type { RecordsWriteMessage } from '../types/records-types.js';
88
import type { ProtocolActionRule, ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage, ProtocolType, ProtocolTypes } from '../types/protocols-types.js';
99

10+
import { FilterUtility } from '../utils/filter.js';
11+
import { Records } from '../utils/records.js';
1012
import { RecordsWrite } from '../interfaces/records-write.js';
1113
import { DwnError, DwnErrorCode } from './dwn-error.js';
1214
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
@@ -37,7 +39,7 @@ export class ProtocolAuthorization {
3739
);
3840

3941
// validate `protocolPath`
40-
await ProtocolAuthorization.verifyProtocolPath(
42+
await ProtocolAuthorization.verifyProtocolPathAndContextId(
4143
tenant,
4244
incomingMessage,
4345
messageStore,
@@ -49,8 +51,8 @@ export class ProtocolAuthorization {
4951
protocolDefinition,
5052
);
5153

52-
// If the incoming message is writing a $globalRole record, validate that the recipient is unique
53-
await ProtocolAuthorization.verifyUniqueRoleRecipient(
54+
// Validate as a role record if the incoming message is writing a role record
55+
await ProtocolAuthorization.verifyAsRoleRecordIfNeeded(
5456
tenant,
5557
incomingMessage,
5658
inboundMessageRuleSet,
@@ -283,7 +285,6 @@ export class ProtocolAuthorization {
283285
}
284286

285287
const protocol = newestRecordsWrite.message.descriptor.protocol!;
286-
const contextId = newestRecordsWrite.message.contextId!;
287288

288289
// keep walking up the chain from the inbound message's parent, until there is no more parent
289290
let currentParentId = newestRecordsWrite.message.descriptor.parentId;
@@ -293,15 +294,18 @@ export class ProtocolAuthorization {
293294
interface : DwnInterfaceName.Records,
294295
method : DwnMethodName.Write,
295296
protocol,
296-
contextId,
297297
recordId : currentParentId
298298
};
299299
const { messages: parentMessages } = await messageStore.query(tenant, [query]);
300300

301-
// We already check the immediate parent in `verifyProtocolPath`, so if it triggers,
302-
// it means a bug that caused an invalid message to be saved to the DWN.
301+
// We already check the immediate parent in `verifyProtocolPathAndContextId` at the time of writing, so if this condition is triggered,
302+
// it means there is an unexpected bug that caused an invalid message being saved to the DWN.
303+
// We add additional defensive check here because returning an unexpected/incorrect ancestor chain could lead to security vulnerabilities.
303304
if (parentMessages.length === 0) {
304-
throw new DwnError(DwnErrorCode.ProtocolAuthorizationParentNotFound, `no parent found with ID ${currentParentId}`);
305+
throw new DwnError(
306+
DwnErrorCode.ProtocolAuthorizationParentNotFoundConstructingAncestorChain,
307+
`Unexpected error that should never trigger: no parent found with ID ${currentParentId} when constructing ancestor message chain.`
308+
);
305309
}
306310

307311
const parent = parentMessages[0] as RecordsWriteMessage;
@@ -314,7 +318,7 @@ export class ProtocolAuthorization {
314318
}
315319

316320
/**
317-
* Gets the rule set corresponding to the given message chain.
321+
* Gets the rule set corresponding to the given protocolPath.
318322
*/
319323
private static getRuleSet(
320324
protocolPath: string,
@@ -332,7 +336,7 @@ export class ProtocolAuthorization {
332336
* Verifies the `protocolPath` declared in the given message (if it is a RecordsWrite) matches the path of actual ancestor chain.
333337
* @throws {DwnError} if fails verification.
334338
*/
335-
private static async verifyProtocolPath(
339+
private static async verifyProtocolPathAndContextId(
336340
tenant: string,
337341
inboundMessage: RecordsWrite,
338342
messageStore: MessageStore
@@ -348,26 +352,43 @@ export class ProtocolAuthorization {
348352
`Declared protocol path '${declaredProtocolPath}' is not valid for records with no parentId'.`
349353
);
350354
}
351-
} else {
352-
const protocol = inboundMessage.message.descriptor.protocol!;
353-
const contextId = inboundMessage.message.contextId!;
354-
const query: Filter = {
355-
interface : DwnInterfaceName.Records,
356-
method : DwnMethodName.Write,
357-
protocol,
358-
contextId,
359-
recordId : parentId
360-
};
361-
const { messages: parentMessages } = await messageStore.query(tenant, [query]);
362-
const parentProtocolPath = (parentMessages as RecordsWriteMessage[])[0]?.descriptor?.protocolPath;
363-
const actualProtocolPath = `${parentProtocolPath}/${declaredTypeName}`;
364-
if (parentProtocolPath === undefined || actualProtocolPath !== declaredProtocolPath) {
365-
throw new DwnError(
366-
DwnErrorCode.ProtocolAuthorizationIncorrectProtocolPath,
367-
`Could not find matching parent record to verify declared protocol path '${declaredProtocolPath}'.`
368-
);
369-
}
355+
return;
370356
}
357+
358+
// Else `parentId` is defined, so we need to verify both protocolPath and contextId
359+
360+
// fetch the parent message
361+
const protocol = inboundMessage.message.descriptor.protocol!;
362+
const query: Filter = {
363+
isLatestBaseState : true, // NOTE: this filter is critical, to ensure are are not returning a deleted parent
364+
interface : DwnInterfaceName.Records,
365+
method : DwnMethodName.Write,
366+
protocol,
367+
recordId : parentId
368+
};
369+
const { messages: parentMessages } = await messageStore.query(tenant, [query]);
370+
const parentMessage = (parentMessages as RecordsWriteMessage[])[0];
371+
372+
// verifying protocolPath of incoming message is a child of the parent message's protocolPath
373+
const parentProtocolPath = parentMessage?.descriptor?.protocolPath;
374+
const expectedProtocolPath = `${parentProtocolPath}/${declaredTypeName}`;
375+
if (expectedProtocolPath !== declaredProtocolPath) {
376+
throw new DwnError(
377+
DwnErrorCode.ProtocolAuthorizationIncorrectProtocolPath,
378+
`Could not find matching parent record to verify declared protocol path '${declaredProtocolPath}'.`
379+
);
380+
}
381+
382+
// verifying contextId of incoming message is a child of the parent message's contextId
383+
const expectedContextId = `${parentMessage.contextId}/${inboundMessage.message.recordId}`;
384+
const actualContextId = inboundMessage.message.contextId;
385+
if (actualContextId !== expectedContextId) {
386+
throw new DwnError(
387+
DwnErrorCode.ProtocolAuthorizationIncorrectContextId,
388+
`Declared contextId '${actualContextId}' is not the same as expected: '${expectedContextId}'.`
389+
);
390+
}
391+
371392
}
372393

373394
/**
@@ -456,7 +477,17 @@ export class ProtocolAuthorization {
456477
'Could not verify $contextRole because contextId is missing'
457478
);
458479
}
459-
roleRecordFilter.contextId = contextId;
480+
481+
// Compute `contextId` prefix filter for fetching the invoked role record.
482+
// e.g. if invoked role path is `Thread/Participant`, and the `contextId` of the message is `threadX/messageY/attachmentZ`,
483+
// then we need to add a prefix filter as `threadX` for the `contextId`
484+
// because the `contextId` of the Participant record would be in the form of be `threadX/participantA`
485+
const ancestorSegmentCountOfRole = protocolRole.split('/').length - 1;
486+
const contextIdSegments = contextId.split('/');
487+
const contextIdPrefix = contextIdSegments.slice(0, ancestorSegmentCountOfRole).join('/');
488+
const contextIdPrefixFilter = FilterUtility.constructPrefixFilterAsRangeFilter(contextIdPrefix);
489+
490+
roleRecordFilter.contextId = contextIdPrefixFilter;
460491
}
461492

462493
const { messages: matchingMessages } = await messageStore.query(tenant, [roleRecordFilter]);
@@ -560,6 +591,7 @@ export class ProtocolAuthorization {
560591
// else the incoming message must be a RecordsDelete because only `update` and `delete` are allowed recipient actions
561592
recordsWriteMessage = ancestorMessageChain[ancestorMessageChain.length - 1];
562593
}
594+
563595
if (recordsWriteMessage.descriptor.recipient === author) {
564596
return;
565597
}
@@ -580,27 +612,30 @@ export class ProtocolAuthorization {
580612
}
581613

582614
/**
583-
* Verifies that writes to a $globalRole or $contextRole record do not have the same recipient as an existing RecordsWrite
584-
* to the same $globalRole or the same $contextRole in the same context.
615+
* If the given RecordsWrite is not a role record, this method does nothing and succeeds immediately.
616+
*
617+
* Else it verifies the validity of the given `RecordsWrite` including:
618+
* 1. The same role has not been assigned to the same entity/recipient.
585619
*/
586-
private static async verifyUniqueRoleRecipient(
620+
private static async verifyAsRoleRecordIfNeeded(
587621
tenant: string,
588622
incomingMessage: RecordsWrite,
589623
inboundMessageRuleSet: ProtocolRuleSet,
590624
messageStore: MessageStore,
591625
): Promise<void> {
592-
const incomingRecordsWrite = incomingMessage;
593626
if (!inboundMessageRuleSet.$globalRole && !inboundMessageRuleSet.$contextRole) {
594627
return;
595628
}
596629

630+
const incomingRecordsWrite = incomingMessage;
597631
const recipient = incomingRecordsWrite.message.descriptor.recipient;
598632
if (recipient === undefined) {
599633
throw new DwnError(
600634
DwnErrorCode.ProtocolAuthorizationRoleMissingRecipient,
601635
'Role records must have a recipient'
602636
);
603637
}
638+
604639
const protocolPath = incomingRecordsWrite.message.descriptor.protocolPath!;
605640
const filter: Filter = {
606641
interface : DwnInterfaceName.Records,
@@ -610,9 +645,13 @@ export class ProtocolAuthorization {
610645
protocolPath,
611646
recipient,
612647
};
648+
613649
if (inboundMessageRuleSet.$contextRole) {
614-
filter.contextId = incomingRecordsWrite.message.contextId!;
650+
const parentContextId = Records.getParentContextFromOfContextId(incomingRecordsWrite.message.contextId)!;
651+
const prefixFilter = FilterUtility.constructPrefixFilterAsRangeFilter(parentContextId);
652+
filter.contextId = prefixFilter;
615653
}
654+
616655
const { messages: matchingMessages } = await messageStore.query(tenant, [filter]);
617656
const matchingRecords = matchingMessages as RecordsWriteMessage[];
618657
const matchingRecordsExceptIncomingRecordId = matchingRecords.filter((recordsWriteMessage) =>

src/core/records-grant-authorization.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,14 @@ export class RecordsGrantAuthorization {
174174
);
175175
}
176176

177-
// If grant specifies either contextId, check that record is that context
178-
if (grantScope.contextId !== undefined && grantScope.contextId !== recordsWriteMessage.contextId) {
179-
throw new DwnError(
180-
DwnErrorCode.RecordsGrantAuthorizationScopeContextIdMismatch,
181-
`Grant scope specifies different contextId than what appears in the record`
182-
);
177+
// If grant specifies a contextId, check that record falls under that contextId
178+
if (grantScope.contextId !== undefined) {
179+
if (recordsWriteMessage.contextId === undefined || !recordsWriteMessage.contextId.startsWith(grantScope.contextId)) {
180+
throw new DwnError(
181+
DwnErrorCode.RecordsGrantAuthorizationScopeContextIdMismatch,
182+
`Grant scope specifies different contextId than what appears in the record`
183+
);
184+
}
183185
}
184186

185187
// If grant specifies protocolPath, check that record is at that protocolPath

0 commit comments

Comments
 (0)