Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#564 - Added delegated grant support for RecordsRead #605

Merged
merged 5 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Q_AND_A.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,10 @@

This design choice is primarily driven by performance considerations. If we were to make `protocolPath` optional, and it is not specified, we would need to search records across protocol paths. Since protocol rules (protocol rule set) are defined at the per protocol path level, this means we would need to parse the protocol rules for every protocol path in the protocol definition to determine which protocol path the invoked role has access to. Then, we would need to make a database query for each qualified protocol path, which could be quite costly. This is not to say that we should never consider it, but this is the current design choice.

- What is the difference between `write` and `update` actions?

(Last update: 2023/11/09)

- `write` - allows a DID to create and update the record they have created
- `update` - allows a DID to update a record, regardless of the initial author

2 changes: 1 addition & 1 deletion json-schemas/interface-methods/records-read.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
],
"properties": {
"authorization": {
"$ref": "https://identity.foundation/dwn/json-schemas/authorization.json"
"$ref": "https://identity.foundation/dwn/json-schemas/authorization-delegated-grant.json"
},
"descriptor": {
"type": "object",
Expand Down
1 change: 1 addition & 0 deletions src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export enum DwnErrorCode {
PrivateKeySignerUnableToDeduceKeyId = 'PrivateKeySignerUnableToDeduceKeyId',
PrivateKeySignerUnsupportedCurve = 'PrivateKeySignerUnsupportedCurve',
ProtocolAuthorizationActionNotAllowed = 'ProtocolAuthorizationActionNotAllowed',
ProtocolAuthorizationActionRulesNotFound = 'ProtocolAuthorizationActionRulesNotFound',
ProtocolAuthorizationDuplicateContextRoleRecipient = 'ProtocolAuthorizationDuplicateContextRoleRecipient',
ProtocolAuthorizationDuplicateGlobalRoleRecipient = 'ProtocolAuthorizationDuplicateGlobalRoleRecipient',
ProtocolAuthorizationIncorrectDataFormat = 'ProtocolAuthorizationIncorrectDataFormat',
Expand Down
3 changes: 2 additions & 1 deletion src/core/grant-authorization.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GenericMessage } from '../types/message-types.js';
import type { MessageStore } from '../types/message-store.js';
import type { PermissionsGrantMessage } from '../types/permissions-types.js';
import type { RecordsMethod } from '../types/records-method.js';

import { Message } from './message.js';
import { DwnError, DwnErrorCode } from './dwn-error.js';
Expand All @@ -15,7 +16,7 @@ export class GrantAuthorization {
*/
public static async authorizeGenericMessage(
tenant: string,
incomingMessage: Message<GenericMessage>,
incomingMessage: RecordsMethod<GenericMessage>,
author: string,
permissionsGrantId: string,
messageStore: MessageStore,
Expand Down
35 changes: 27 additions & 8 deletions src/core/message.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js';
import type { GeneralJws } from '../types/jws-types.js';
import type { RecordsMethod } from '../types/records-method.js';
import type { Signer } from '../types/signer.js';
import type { AuthorizationModel, Descriptor, GenericMessage, GenericSignaturePayload } from '../types/message-types.js';

Expand All @@ -12,17 +13,35 @@ import { removeUndefinedProperties } from '../utils/object.js';
import { validateJsonSchema } from '../schema-validator.js';
import { DwnError, DwnErrorCode } from './dwn-error.js';

export abstract class Message<M extends GenericMessage> {
readonly message: M;
readonly signaturePayload: GenericSignaturePayload | undefined;
readonly author: string | undefined;
export abstract class Message<M extends GenericMessage> implements RecordsMethod<M> {
private _message: M;
public get message(): M {
return this._message as M;
}

private _author: string | undefined;
public get author(): string | undefined {
return this._author;
}

constructor(message: M) {
this.message = message;
private _signaturePayload: GenericSignaturePayload | undefined;
public get signaturePayload(): GenericSignaturePayload | undefined {
return this._signaturePayload;
}

protected constructor(message: M) {
this._message = message;

if (message.authorization !== undefined) {
this.signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature);
this.author = Message.getSigner(message as GenericMessage);
// if the message authorization contains author delegated grant, the author would be the grantor of the grant
// else the author would be the signer of the message
if (message.authorization.authorDelegatedGrant !== undefined) {
this._author = Message.getSigner(message.authorization.authorDelegatedGrant);
} else {
this._author = Message.getSigner(message as GenericMessage);
}

this._signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature);
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/core/protocol-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,9 @@ export class ProtocolAuthorization {

/**
* Returns a list of ProtocolAction(s) based on the incoming message, one of which must be allowed for the message to be authorized.
* NOTE: the reason why there could be multiple actions is because in case of an "update" RecordsWrite by the original record author,
* the RecordsWrite can either be authorized by a `write` or `update` allow rule. It is important to recognize that the `write` access that allowed
* the original record author to create the record maybe revoked (e.g. by role revocation) by the time an "update" by the same author is attempted.
*/
private static async getActionsSeekingARuleMatch(
tenant: string,
Expand Down Expand Up @@ -529,7 +532,7 @@ export class ProtocolAuthorization {
// We have already checked that the message is not from tenant, owner, or permissionsGrant
if (actionRules === undefined) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationActionNotAllowed,
DwnErrorCode.ProtocolAuthorizationActionRulesNotFound,
`no action rule defined for ${incomingMessageMethod}, ${author} is unauthorized`
);
}
Expand Down
3 changes: 0 additions & 3 deletions src/interfaces/protocols-configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ export type ProtocolsConfigureOptions = {
};

export class ProtocolsConfigure extends Message<ProtocolsConfigureMessage> {
// JSON Schema guarantees presence of `authorization` which contains author DID
readonly author!: string;

public static async parse(message: ProtocolsConfigureMessage): Promise<ProtocolsConfigure> {
Message.validateJsonSchema(message);
ProtocolsConfigure.validateProtocolDefinition(message.descriptor.definition);
Expand Down
48 changes: 3 additions & 45 deletions src/interfaces/records-query.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js';
import type { Pagination } from '../types/message-types.js';
import type { Signer } from '../types/signer.js';
import type { DateSort, RecordsFilter, RecordsQueryDescriptor, RecordsQueryMessage } from '../types/records-types.js';
import type { GenericMessage, GenericSignaturePayload, Pagination } from '../types/message-types.js';

import { Jws } from '../utils/jws.js';
import { Message } from '../core/message.js';
import { Records } from '../utils/records.js';
import { removeUndefinedProperties } from '../utils/object.js';
Expand All @@ -29,49 +28,7 @@ export type RecordsQueryOptions = {
/**
* A class representing a RecordsQuery DWN message.
*/
export class RecordsQuery {
private _message: RecordsQueryMessage;
/**
* Valid JSON message representing this RecordsQuery.
*/
public get message(): RecordsQueryMessage {
return this._message as RecordsQueryMessage;
}

private _author: string | undefined;
/**
* DID of the logical author of this message.
* NOTE: we say "logical" author because a message can be signed by a delegate of the actual author,
* in which case the author DID would not be the same as the signer/delegate DID,
* but be the DID of the grantor (`grantedBy`) of the delegated grant presented.
*/
public get author(): string | undefined {
return this._author;
}

private _signaturePayload: GenericSignaturePayload | undefined;
/**
* Decoded payload of the signature of this message.
*/
public get signaturePayload(): GenericSignaturePayload | undefined {
return this._signaturePayload;
}

private constructor(message: RecordsQueryMessage) {
this._message = message;

if (message.authorization !== undefined) {
// if the message authorization contains author delegated grant, the author would be the grantor of the grant
// else the author would be the signer of the message
if (message.authorization.authorDelegatedGrant !== undefined) {
this._author = Message.getSigner(message.authorization.authorDelegatedGrant);
} else {
this._author = Message.getSigner(message as GenericMessage);
}

this._signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature);
}
}
export class RecordsQuery extends Message<RecordsQueryMessage> {

public static async parse(message: RecordsQueryMessage): Promise<RecordsQuery> {
let signaturePayload;
Expand All @@ -95,6 +52,7 @@ export class RecordsQuery {
if (message.descriptor.filter.schema !== undefined) {
validateSchemaUrlNormalized(message.descriptor.filter.schema);
}

Time.validateTimestamp(message.descriptor.messageTimestamp);

return new RecordsQuery(message);
Expand Down
15 changes: 13 additions & 2 deletions src/interfaces/records-read.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js';
import type { Signer } from '../types/signer.js';
import type { RecordsFilter , RecordsReadDescriptor, RecordsReadMessage } from '../types/records-types.js';

Expand All @@ -17,14 +18,23 @@ export type RecordsReadOptions = {
* The protocol path to a $globalRole record whose recipient is the author of this RecordsRead
*/
protocolRole?: string;

/**
* The delegated grant to sign on behalf of the logical author, which is the grantor (`grantedBy`) of the delegated grant.
*/
delegatedGrant?: DelegatedGrantMessage;
};

export class RecordsRead extends Message<RecordsReadMessage> {

public static async parse(message: RecordsReadMessage): Promise<RecordsRead> {
let signaturePayload;
if (message.authorization !== undefined) {
await Message.validateMessageSignatureIntegrity(message.authorization.signature, message.descriptor);
signaturePayload = await Message.validateMessageSignatureIntegrity(message.authorization.signature, message.descriptor);
}

Records.validateDelegatedGrantReferentialIntegrity(message, signaturePayload);

Time.validateTimestamp(message.descriptor.messageTimestamp);

const recordsRead = new RecordsRead(message);
Expand Down Expand Up @@ -58,7 +68,8 @@ export class RecordsRead extends Message<RecordsReadMessage> {
descriptor,
signer,
permissionsGrantId,
protocolRole
protocolRole,
delegatedGrant: options.delegatedGrant
});
}
const message: RecordsReadMessage = { descriptor, authorization };
Expand Down
15 changes: 2 additions & 13 deletions src/interfaces/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'
import type { GeneralJws } from '../types/jws-types.js';
import type { MessageStore } from '../types/message-store.js';
import type { PublicJwk } from '../types/jose-types.js';
import type { RecordsMethod } from '../types/records-method.js';
import type { Signer } from '../types/signer.js';
import type {
EncryptedKey,
Expand Down Expand Up @@ -140,7 +141,7 @@ export type CreateFromOptions = {
/**
* A class representing a RecordsWrite DWN message.
*/
export class RecordsWrite {
export class RecordsWrite implements RecordsMethod<RecordsWriteMessage> {
private _message: InternalRecordsWriteMessage;
/**
* Valid JSON message representing this RecordsWrite.
Expand All @@ -158,28 +159,16 @@ export class RecordsWrite {
}

private _author: string | undefined;
/**
* DID of the logical author of this message.
* NOTE: we say "logical" author because a message can be signed by a delegate of the actual author,
* in which case the author DID would not be the same as the signer/delegate DID,
* but be the DID of the grantor (`grantedBy`) of the delegated grant presented.
*/
public get author(): string | undefined {
return this._author;
}

private _signaturePayload: RecordsWriteSignaturePayload | undefined;
/**
* Decoded payload of the signature of this message.
*/
public get signaturePayload(): RecordsWriteSignaturePayload | undefined {
return this._signaturePayload;
}

private _owner: string | undefined;
/**
* DID of owner of this message.
*/
public get owner(): string | undefined {
return this._owner;
}
Expand Down
Empty file removed src/types/authorization-model.ts
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
Empty file.
24 changes: 24 additions & 0 deletions src/types/records-method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { GenericMessage, GenericSignaturePayload } from '../types/message-types.js';

/**
* A signer that is capable of generating a digital signature over any given bytes.
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
*/
export interface RecordsMethod<M extends GenericMessage> {
/**
* Valid JSON message representing this RecordsQuery.
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
*/
get message(): M;

/**
* DID of the logical author of this message.
* NOTE: we say "logical" author because a message can be signed by a delegate of the actual author,
* in which case the author DID would not be the same as the signer/delegate DID,
* but be the DID of the grantor (`grantedBy`) of the delegated grant presented.
*/
get author(): string | undefined;

/**
* Decoded payload of the signature of this message.
*/
get signaturePayload(): GenericSignaturePayload | undefined;
}
4 changes: 2 additions & 2 deletions src/utils/records.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DerivedPrivateJwk } from './hd-key.js';
import type { Readable } from 'readable-stream';
import type { Filter, GenericSignaturePayload, RangeFilter } from '../types/message-types.js';
import type { RangeCriterion, RecordsFilter, RecordsQueryMessage, RecordsWriteDescriptor, RecordsWriteMessage } from '../types/records-types.js';
import type { RangeCriterion, RecordsFilter, RecordsQueryMessage, RecordsReadMessage, RecordsWriteDescriptor, RecordsWriteMessage } from '../types/records-types.js';

import { Encoder } from './encoder.js';
import { Encryption } from './encryption.js';
Expand Down Expand Up @@ -296,7 +296,7 @@ export class Records {
* Usage of this property is purely for performance optimization so we don't have to decode the signature payload again.
*/
public static validateDelegatedGrantReferentialIntegrity(
message: RecordsQueryMessage | RecordsWriteMessage,
message: RecordsReadMessage | RecordsQueryMessage | RecordsWriteMessage,
signaturePayload: GenericSignaturePayload | undefined
): void {
// `deletedGrantId` in the payload of the message signature and `authorDelegatedGrant` in `authorization` must both exist or be both undefined
Expand Down
4 changes: 2 additions & 2 deletions tests/handlers/records-read.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ export function testRecordsReadHandler(): void {
});
const recordsReadWithoutGrantReply = await dwn.processMessage(alice.did, recordsReadWithoutGrant.message);
expect(recordsReadWithoutGrantReply.status.code).to.equal(401);
expect(recordsReadWithoutGrantReply.status.detail).to.contain('no action rule defined for Read');
expect(recordsReadWithoutGrantReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationActionRulesNotFound);

// Bob is able to read the record when he uses the PermissionsGrant
const recordsReadWithGrant = await RecordsRead.create({
Expand Down Expand Up @@ -963,7 +963,7 @@ export function testRecordsReadHandler(): void {
});
const recordsReadWithoutGrantReply = await dwn.processMessage(alice.did, recordsReadWithoutGrant.message);
expect(recordsReadWithoutGrantReply.status.code).to.equal(401);
expect(recordsReadWithoutGrantReply.status.detail).to.contain('no action rule defined for Read');
expect(recordsReadWithoutGrantReply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationActionRulesNotFound);

// Bob is able to read the record when he uses the PermissionsGrant
const recordsReadWithGrant = await RecordsRead.create({
Expand Down
2 changes: 1 addition & 1 deletion tests/handlers/records-write.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2669,7 +2669,7 @@ export function testRecordsWriteHandler(): void {

reply = await dwn.processMessage(alice.did, bobWriteMessageData.message, bobWriteMessageData.dataStream);
expect(reply.status.code).to.equal(401);
expect(reply.status.detail).to.contain(`no action rule defined for Write`);
expect(reply.status.detail).to.contain(DwnErrorCode.ProtocolAuthorizationActionRulesNotFound);
});

it('should look up recipient path with ancestor depth of 2+ (excluding self) in action rule correctly', async () => {
Expand Down
Loading