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 scoping for RecordsWrite #617

Merged
merged 6 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
24 changes: 10 additions & 14 deletions src/core/grant-authorization.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { GenericMessage } from '../types/message-types.js';
import type { MessageInterface } from '../types/message-interface.js';
import type { MessageStore } from '../types/message-store.js';
import type { PermissionsGrantMessage } from '../types/permissions-types.js';

Expand All @@ -12,20 +11,18 @@ export class GrantAuthorization {
/**
* Performs PermissionsGrant-based authorization against the given message
* Does not validate grant `conditions` or `scope` beyond `interface` and `method`
* @throws {Error} if authorization fails
* @throws {DwnError} if authorization fails
*/
public static async authorizeGenericMessage(
tenant: string,
incomingMessage: MessageInterface<GenericMessage>,
incomingMessage: GenericMessage,
author: string,
permissionsGrantId: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<PermissionsGrantMessage> {

const incomingMessageDescriptor = incomingMessage.message.descriptor;
): Promise<void> {

// Fetch grant
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, permissionsGrantId);
const incomingMessageDescriptor = incomingMessage.descriptor;
const permissionsGrantId = await Message.getCid(permissionsGrantMessage);

GrantAuthorization.verifyGrantedToAndGrantedFor(author, tenant, permissionsGrantMessage);

Expand All @@ -45,16 +42,14 @@ export class GrantAuthorization {
permissionsGrantMessage,
permissionsGrantId
);

return permissionsGrantMessage;
}

/**
* Fetches PermissionsGrantMessage with CID `permissionsGrantId`.
* @returns the PermissionsGrantMessage with CID `permissionsGrantId` if message exists
* @throws {Error} if PermissionsGrantMessage with CID `permissionsGrantId` does not exist
*/
private static async fetchGrant(
public static async fetchGrant(
tenant: string,
messageStore: MessageStore,
permissionsGrantId: string,
Expand Down Expand Up @@ -101,7 +96,7 @@ export class GrantAuthorization {
* Verify that the incoming message is within the allowed time frame of the grant,
* and the grant has not been revoked.
* @param permissionsGrantId Purely being passed as an optimization. Technically can be computed from `permissionsGrantMessage`.
* @throws {Error} if incomingMessage has timestamp for a time in which the grant is not active.
* @throws {DwnError} if incomingMessage has timestamp for a time in which the grant is not active.
*/
private static async verifyGrantActive(
tenant: string,
Expand Down Expand Up @@ -144,7 +139,8 @@ export class GrantAuthorization {

/**
* Verify that the `interface` and `method` grant scopes match the incoming message
* @throws {Error} if the `interface` and `method` of the incoming message do not match the scope of the PermissionsGrant
* @param permissionsGrantId Purely being passed for logging purposes.
* @throws {DwnError} if the `interface` and `method` of the incoming message do not match the scope of the PermissionsGrant
*/
private static async verifyGrantScopeInterfaceAndMethod(
dwnInterface: string,
Expand Down
46 changes: 24 additions & 22 deletions src/core/records-grant-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { MessageStore } from '../types/message-store.js';
import type { PermissionsGrantMessage } from '../types/permissions-types.js';
import type { RecordsPermissionScope } from '../types/permissions-grant-descriptor.js';
import type { RecordsRead } from '../interfaces/records-read.js';
import type { RecordsWrite } from '../interfaces/records-write.js';
import type { RecordsWriteMessage } from '../types/records-types.js';

import { GrantAuthorization } from './grant-authorization.js';
import { PermissionsConditionPublication } from '../types/permissions-grant-descriptor.js';
Expand All @@ -14,15 +14,16 @@ export class RecordsGrantAuthorization {
*/
public static async authorizeWrite(
tenant: string,
incomingMessage: RecordsWrite,
incomingMessage: RecordsWriteMessage,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
const permissionsGrantMessage = await GrantAuthorization.authorizeGenericMessage(
await GrantAuthorization.authorizeGenericMessage(
tenant,
incomingMessage,
author,
incomingMessage.signaturePayload!.permissionsGrantId!,
permissionsGrantMessage,
messageStore
);

Expand All @@ -37,47 +38,48 @@ export class RecordsGrantAuthorization {
public static async authorizeRead(
tenant: string,
incomingMessage: RecordsRead,
newestRecordsWrite: RecordsWrite,
newestRecordsWriteMessage: RecordsWriteMessage,
author: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
): Promise<void> {
const permissionsGrantMessage = await GrantAuthorization.authorizeGenericMessage(
await GrantAuthorization.authorizeGenericMessage(
tenant,
incomingMessage,
incomingMessage.message,
author,
incomingMessage.signaturePayload!.permissionsGrantId!,
permissionsGrantMessage,
messageStore
);

RecordsGrantAuthorization.verifyScope(newestRecordsWrite, permissionsGrantMessage);
RecordsGrantAuthorization.verifyScope(newestRecordsWriteMessage, permissionsGrantMessage);
}

/**
* @param recordsWrite The source of the record being authorized. If the incoming message is a write,
* then this is the incoming RecordsWrite. Otherwise, it is the newest existing RecordsWrite.
*/
private static verifyScope(
recordsWrite: RecordsWrite,
recordsWriteMessage: RecordsWriteMessage,
permissionsGrantMessage: PermissionsGrantMessage,
): void {
const grantScope = permissionsGrantMessage.descriptor.scope as RecordsPermissionScope;

if (RecordsGrantAuthorization.isUnrestrictedScope(grantScope)) {
// scope has no restrictions beyond interface and method. Message is authorized to access any record.
return;
} else if (recordsWrite.message.descriptor.protocol !== undefined) {
} else if (recordsWriteMessage.descriptor.protocol !== undefined) {
// authorization of protocol records must have grants that explicitly include the protocol
RecordsGrantAuthorization.authorizeProtocolRecord(recordsWrite, grantScope);
RecordsGrantAuthorization.authorizeProtocolRecord(recordsWriteMessage, grantScope);
} else {
RecordsGrantAuthorization.authorizeFlatRecord(recordsWrite, grantScope);
RecordsGrantAuthorization.authorizeFlatRecord(recordsWriteMessage, grantScope);
}
}

/**
* Authorizes a grant scope for a protocol record
*/
private static authorizeProtocolRecord(
recordsWrite: RecordsWrite,
recordsWriteMessage: RecordsWriteMessage,
grantScope: RecordsPermissionScope
): void {
// Protocol records must have grants specifying the protocol
Expand All @@ -89,23 +91,23 @@ export class RecordsGrantAuthorization {
}

// The record's protocol must match the protocol specified in the record
if (grantScope.protocol !== recordsWrite.message.descriptor.protocol) {
if (grantScope.protocol !== recordsWriteMessage.descriptor.protocol) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeProtocolMismatch,
`Grant scope specifies different protocol than what appears in the record`
);
}

// If grant specifies either contextId, check that record is that context
if (grantScope.contextId !== undefined && grantScope.contextId !== recordsWrite.message.contextId) {
if (grantScope.contextId !== undefined && grantScope.contextId !== recordsWriteMessage.contextId) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeContextIdMismatch,
`Grant scope specifies different contextId than what appears in the record`
);
}

// If grant specifies protocolPath, check that record is at that protocolPath
if (grantScope.protocolPath !== undefined && grantScope.protocolPath !== recordsWrite.message.descriptor.protocolPath) {
if (grantScope.protocolPath !== undefined && grantScope.protocolPath !== recordsWriteMessage.descriptor.protocolPath) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeProtocolPathMismatch,
`Grant scope specifies different protocolPath than what appears in the record`
Expand All @@ -117,11 +119,11 @@ export class RecordsGrantAuthorization {
* Authorizes a grant scope for a non-protocol record
*/
private static authorizeFlatRecord(
recordsWrite: RecordsWrite,
recordsWriteMessage: RecordsWriteMessage,
grantScope: RecordsPermissionScope
): void {
if (grantScope.schema !== undefined) {
if (grantScope.schema !== recordsWrite.message.descriptor.schema) {
if (grantScope.schema !== recordsWriteMessage.descriptor.schema) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeSchema,
`Record does not have schema in PermissionsGrant scope with schema '${grantScope.schema}'`
Expand All @@ -134,19 +136,19 @@ export class RecordsGrantAuthorization {
* Verifies grant `conditions`.
* Currently the only condition is `published` which only applies to RecordsWrites
*/
private static verifyConditions(incomingMessage: RecordsWrite, permissionsGrantMessage: PermissionsGrantMessage): void {
private static verifyConditions(recordsWriteMessage: RecordsWriteMessage, permissionsGrantMessage: PermissionsGrantMessage): void {
const conditions = permissionsGrantMessage.descriptor.conditions;

// If conditions require publication, RecordsWrite must have `published` === true
if (conditions?.publication === PermissionsConditionPublication.Required && !incomingMessage.message.descriptor.published) {
if (conditions?.publication === PermissionsConditionPublication.Required && !recordsWriteMessage.descriptor.published) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationConditionPublicationRequired,
'PermissionsGrant requires message to be published'
);
}

// if conditions prohibit publication, RecordsWrite must have published === false or undefined
if (conditions?.publication === PermissionsConditionPublication.Prohibited && incomingMessage.message.descriptor.published) {
if (conditions?.publication === PermissionsConditionPublication.Prohibited && recordsWriteMessage.descriptor.published) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationConditionPublicationProhibited,
'PermissionsGrant prohibits message from being published'
Expand Down
6 changes: 5 additions & 1 deletion src/handlers/records-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { authenticate } from '../core/auth.js';
import { DataStream } from '../utils/data-stream.js';
import { DwnInterfaceName } from '../enums/dwn-interface-method.js';
import { Encoder } from '../utils/encoder.js';
import { GrantAuthorization } from '../core/grant-authorization.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
Expand Down Expand Up @@ -113,7 +114,10 @@ export class RecordsReadHandler implements MethodHandler {
// The recipient of a message may always read it
return;
} else if (recordsRead.author !== undefined && recordsRead.signaturePayload!.permissionsGrantId !== undefined) {
await RecordsGrantAuthorization.authorizeRead(tenant, recordsRead, newestRecordsWrite, recordsRead.author, messageStore);
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, recordsRead.signaturePayload!.permissionsGrantId);
await RecordsGrantAuthorization.authorizeRead(
tenant, recordsRead, newestRecordsWrite.message, recordsRead.author, permissionsGrantMessage, messageStore
);
} else if (descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeRead(tenant, recordsRead, newestRecordsWrite, messageStore);
} else {
Expand Down
8 changes: 7 additions & 1 deletion src/handlers/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Cid } from '../utils/cid.js';
import { DataStream } from '../utils/data-stream.js';
import { DwnConstant } from '../core/dwn-constant.js';
import { Encoder } from '../utils/encoder.js';
import { GrantAuthorization } from '../core/grant-authorization.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
Expand Down Expand Up @@ -260,6 +261,10 @@ export class RecordsWriteHandler implements MethodHandler {
);
}

if (recordsWrite.isSignedByDelegatee) {
await recordsWrite.authorizeDelegatee(messageStore);
}

if (recordsWrite.owner !== undefined) {
// if incoming message is a write retained by this tenant, we by-design always allow
// NOTE: the "owner === tenant" check is already done earlier in this method
Expand All @@ -268,7 +273,8 @@ export class RecordsWriteHandler implements MethodHandler {
// if author is the same as the target tenant, we can directly grant access
return;
} else if (recordsWrite.author !== undefined && recordsWrite.signaturePayload!.permissionsGrantId !== undefined) {
await RecordsGrantAuthorization.authorizeWrite(tenant, recordsWrite, recordsWrite.author, messageStore);
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, recordsWrite.signaturePayload!.permissionsGrantId);
await RecordsGrantAuthorization.authorizeWrite(tenant, recordsWrite.message, recordsWrite.author, permissionsGrantMessage, messageStore);
} else if (recordsWrite.message.descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeWrite(tenant, recordsWrite, messageStore);
} else {
Expand Down
5 changes: 3 additions & 2 deletions src/interfaces/protocols-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ export class ProtocolsQuery extends AbstractMessage<ProtocolsQueryMessage> {
if (this.author === tenant) {
return;
} else if (this.author !== undefined && this.signaturePayload!.permissionsGrantId) {
const permissionsGrantMessage = await GrantAuthorization.fetchGrant(tenant, messageStore, this.signaturePayload!.permissionsGrantId);
await GrantAuthorization.authorizeGenericMessage(
tenant,
this,
this.message,
this.author,
this.signaturePayload!.permissionsGrantId,
permissionsGrantMessage,
messageStore
);
} else {
Expand Down
23 changes: 23 additions & 0 deletions src/interfaces/records-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Jws } from '../utils/jws.js';
import { KeyDerivationScheme } from '../utils/hd-key.js';
import { Message } from '../core/message.js';
import { Records } from '../utils/records.js';
import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Secp256k1 } from '../utils/secp256k1.js';
import { Time } from '../utils/time.js';
Expand Down Expand Up @@ -182,6 +183,21 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
return this._ownerSignaturePayload;
}

/**
* If this message is signed by a delegated entity.
*/
public get isSignedByDelegatee(): boolean {
return this._message.authorization?.authorDelegatedGrant !== undefined;
}

/**
* Gets the signer of this message.
* This is not to be confused with the logical author of the message.
*/
public get signer(): string | undefined {
return Message.getSigner(this._message);
}

readonly attesters: string[];

private constructor(message: InternalRecordsWriteMessage) {
Expand Down Expand Up @@ -696,6 +712,13 @@ export class RecordsWrite implements MessageInterface<RecordsWriteMessage> {
return indexes;
}

public async authorizeDelegatee(messageStore: MessageStore): Promise<void> {
const grantedTo = this.signer!;
const grantedFor = this.author!;
const delegatedGrant = this.message.authorization.authorDelegatedGrant!;
await RecordsGrantAuthorization.authorizeWrite(grantedFor, this.message, grantedTo, delegatedGrant, messageStore);
}


/**
* Checks if the given message is the initial entry of a record.
Expand Down
3 changes: 2 additions & 1 deletion src/utils/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,8 @@ export class Records {

// when delegated grant exists, the grantee (grantedTo) must be the same as the signer of the message
if (authorDelegatedGrantDefined) {
const grantedTo = message.authorization!.authorDelegatedGrant!.descriptor.grantedTo;
const delegatedGrant = message.authorization!.authorDelegatedGrant!;
const grantedTo = delegatedGrant.descriptor.grantedTo;
const signer = Message.getSigner(message);
if (grantedTo !== signer) {
throw new DwnError(
Expand Down
15 changes: 15 additions & 0 deletions tests/interfaces/records-write.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,21 @@ describe('RecordsWrite', () => {
});
});

describe('isSignedByDelegatee()', () => {
it('should return false if the given RecordsWrite is not signed at all', async () => {
const data = new TextEncoder().encode('any data');
const recordsWrite = await RecordsWrite.create({
protocol : 'unused',
protocolPath : 'unused',
schema : 'unused',
dataFormat : 'unused',
data
});

const isSignedByDelegatee = recordsWrite.isSignedByDelegatee;
expect(isSignedByDelegatee).to.be.false;
});
});

describe('isInitialWrite()', () => {
it('should return false if given message is not a RecordsWrite', async () => {
Expand Down
Loading