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

Event and Records Subscriptions #607

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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: 5 additions & 1 deletion build/compile-validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Definitions from '../json-schemas/definitions.json' assert { type: 'json'
import EventsFilter from '../json-schemas/interface-methods/events-filter.json' assert { type: 'json' };
import EventsGet from '../json-schemas/interface-methods/events-get.json' assert { type: 'json' };
import EventsQuery from '../json-schemas/interface-methods/events-query.json' assert { type: 'json' };
import EventsSubscribe from '../json-schemas/interface-methods/events-subscribe.json' assert { type: 'json' };
import GeneralJwk from '../json-schemas/jwk/general-jwk.json' assert { type: 'json' };
import GeneralJws from '../json-schemas/general-jws.json' assert { type: 'json' };
import GenericSignaturePayload from '../json-schemas/signature-payloads/generic-signature-payload.json' assert { type: 'json' };
Expand All @@ -44,6 +45,7 @@ import RecordsDelete from '../json-schemas/interface-methods/records-delete.json
import RecordsFilter from '../json-schemas/interface-methods/records-filter.json' assert { type: 'json' };
import RecordsQuery from '../json-schemas/interface-methods/records-query.json' assert { type: 'json' };
import RecordsRead from '../json-schemas/interface-methods/records-read.json' assert { type: 'json' };
import RecordsSubscribe from '../json-schemas/interface-methods/records-subscribe.json' assert { type: 'json' };
import RecordsWrite from '../json-schemas/interface-methods/records-write.json' assert { type: 'json' };
import RecordsWriteSignaturePayload from '../json-schemas/signature-payloads/records-write-signature-payload.json' assert { type: 'json' };
import RecordsWriteUnidentified from '../json-schemas/interface-methods/records-write-unidentified.json' assert { type: 'json' };
Expand All @@ -54,10 +56,12 @@ const schemas = {
AuthorizationOwner,
RecordsDelete,
RecordsQuery,
RecordsSubscribe,
RecordsWrite,
RecordsWriteUnidentified,
EventsFilter,
EventsGet,
EventsSubscribe,
EventsQuery,
Definitions,
GeneralJwk,
Expand Down Expand Up @@ -92,4 +96,4 @@ const moduleCode = standaloneCode(ajv);
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));

await mkdirp(path.join(__dirname, '../generated'));
fs.writeFileSync(path.join(__dirname, '../generated/precompiled-validators.js'), moduleCode);
fs.writeFileSync(path.join(__dirname, '../generated/precompiled-validators.js'), moduleCode);
47 changes: 47 additions & 0 deletions json-schemas/interface-methods/events-subscribe.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://identity.foundation/dwn/json-schemas/events-subscribe.json",
"type": "object",
"additionalProperties": false,
"required": [
"descriptor"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanity: authorization is required also no?

],
"properties": {
"authorization": {
"$ref": "https://identity.foundation/dwn/json-schemas/authorization.json"
},
"descriptor": {
"type": "object",
"additionalProperties": false,
"required": [
"interface",
"method",
"messageTimestamp",
"filters"
],
"properties": {
"interface": {
"enum": [
"Events"
],
"type": "string"
},
"method": {
"enum": [
"Subscribe"
],
"type": "string"
},
"messageTimestamp": {
"type": "string"
},
"filters": {
"type": "array",
"items": {
"$ref": "https://identity.foundation/dwn/json-schemas/events-filter.json"
}
}
}
}
}
}
1 change: 1 addition & 0 deletions json-schemas/interface-methods/protocol-rule-set.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"enum": [
"delete",
"query",
"subscribe",
"read",
"update",
"write"
Expand Down
44 changes: 44 additions & 0 deletions json-schemas/interface-methods/records-subscribe.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://identity.foundation/dwn/json-schemas/records-subscribe.json",
"type": "object",
"additionalProperties": false,
"required": [
"descriptor"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanity: authorization is required also no?

],
"properties": {
"authorization": {
"$ref": "https://identity.foundation/dwn/json-schemas/authorization-delegated-grant.json"
},
"descriptor": {
"type": "object",
"additionalProperties": false,
"required": [
"interface",
"method",
"messageTimestamp",
"filter"
],
"properties": {
"interface": {
"enum": [
"Records"
],
"type": "string"
},
"method": {
"enum": [
"Subscribe"
],
"type": "string"
},
"messageTimestamp": {
"$ref": "https://identity.foundation/dwn/json-schemas/defs.json#/definitions/date-time"
},
"filter": {
"$ref": "https://identity.foundation/dwn/json-schemas/records-filter.json"
}
}
}
}
}
3 changes: 3 additions & 0 deletions json-schemas/permissions/permissions-definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
},
{
"$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-query-scope"
},
{
"$ref": "https://identity.foundation/dwn/json-schemas/permissions/scopes.json#/definitions/records-subscribe-scope"
}
]
},
Expand Down
18 changes: 18 additions & 0 deletions json-schemas/permissions/scopes.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@
"type": "string"
}
}
},
"records-subscribe-scope": {
"type": "object",
"required": [
"interface",
"method"
],
"properties": {
"interface": {
"const": "Records"
},
"method": {
"const": "Subscribe"
},
"protocol": {
"type": "string"
}
}
}
}
}
5 changes: 5 additions & 0 deletions src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export enum DwnErrorCode {
DidNotValid = 'DidNotValid',
DidResolutionFailed = 'DidResolutionFailed',
Ed25519InvalidJwk = 'Ed25519InvalidJwk',
EventStreamSubscriptionNotSupported = 'EventStreamSubscriptionNotSupported',
GeneralJwsVerifierGetPublicKeyNotFound = 'GeneralJwsVerifierGetPublicKeyNotFound',
GeneralJwsVerifierInvalidSignature = 'GeneralJwsVerifierInvalidSignature',
GrantAuthorizationGrantExpired = 'GrantAuthorizationGrantExpired',
Expand Down Expand Up @@ -88,6 +89,7 @@ export enum DwnErrorCode {
RecordsGrantAuthorizationDeleteProtocolScopeMismatch = 'RecordsGrantAuthorizationDeleteProtocolScopeMismatch',
RecordsGrantAuthorizationQueryProtocolScopeMismatch = 'RecordsGrantAuthorizationQueryProtocolScopeMismatch',
RecordsGrantAuthorizationScopeContextIdMismatch = 'RecordsGrantAuthorizationScopeContextIdMismatch',
RecordsGrantAuthorizationSubscribeProtocolScopeMismatch = 'RecordsGrantAuthorizationSubscribeProtocolScopeMismatch',
RecordsGrantAuthorizationScopeNotProtocol = 'RecordsGrantAuthorizationScopeNotProtocol',
RecordsGrantAuthorizationScopeProtocolMismatch = 'RecordsGrantAuthorizationScopeProtocolMismatch',
RecordsGrantAuthorizationScopeProtocolPathMismatch = 'RecordsGrantAuthorizationScopeProtocolPathMismatch',
Expand All @@ -99,6 +101,9 @@ export enum DwnErrorCode {
RecordsQueryFilterMissingRequiredProperties = 'RecordsQueryFilterMissingRequiredProperties',
RecordsReadReturnedMultiple = 'RecordsReadReturnedMultiple',
RecordsReadAuthorizationFailed = 'RecordsReadAuthorizationFailed',
RecordsSubscribeFilterMissingRequiredProperties = 'RecordsSubscribeFilterMissingRequiredProperties',
RecordsSubscribeUnauthorized = 'RecordsSubscribeUnauthorized',
RecordsSubscribeUnknownError = 'RecordsSubscribeUnknownError',
RecordsSchemasDerivationSchemeMissingSchema = 'RecordsSchemasDerivationSchemeMissingSchema',
RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch = 'RecordsValidateIntegrityDelegatedGrantAndIdExistenceMismatch',
RecordsValidateIntegrityGrantedToAndSignerMismatch = 'RecordsValidateIntegrityGrantedToAndSignerMismatch',
Expand Down
7 changes: 6 additions & 1 deletion src/core/message-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { MessagesGetReplyEntry } from '../types/messages-types.js';
import type { ProtocolsConfigureMessage } from '../types/protocols-types.js';
import type { Readable } from 'readable-stream';
import type { RecordsWriteMessage } from '../types/records-types.js';
import type { GenericMessageReply, QueryResultEntry } from '../types/message-types.js';
import type { GenericMessageReply, GenericMessageSubscription, QueryResultEntry } from '../types/message-types.js';

export function messageReplyFromError(e: unknown, code: number): GenericMessageReply {

Expand Down Expand Up @@ -39,4 +39,9 @@ export type UnionMessageReply = GenericMessageReply & {
* Mutually exclusive with `record`.
*/
cursor?: string;

/**
* A subscription object if a subscription was requested.
*/
subscription?: GenericMessageSubscription;
};
51 changes: 48 additions & 3 deletions src/core/protocol-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MessageStore } from '../types/message-store.js';
import type { RecordsDelete } from '../interfaces/records-delete.js';
import type { RecordsQuery } from '../interfaces/records-query.js';
import type { RecordsRead } from '../interfaces/records-read.js';
import type { RecordsSubscribe } from '../interfaces/records-subscribe.js';
import type { RecordsWriteMessage } from '../types/records-types.js';
import type { ProtocolActionRule, ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage, ProtocolType, ProtocolTypes } from '../types/protocols-types.js';

Expand Down Expand Up @@ -152,6 +153,47 @@ export class ProtocolAuthorization {
);
}

public static async authorizeSubscription(
tenant: string,
incomingMessage: RecordsSubscribe,
messageStore: MessageStore,
): Promise<void> {
// validate that required properties exist in subscription filter
const { protocol, protocolPath, contextId } = incomingMessage.message.descriptor.filter;

// fetch the protocol definition
const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition(
tenant,
protocol!, // `authorizeSubscription` is only called if `protocol` is present
messageStore,
);

// get the rule set for the inbound message
const inboundMessageRuleSet = ProtocolAuthorization.getRuleSet(
protocolPath!, // presence of `protocolPath` is verified in `parse()`
protocolDefinition,
);

// If the incoming message has `protocolRole` in the descriptor, validate the invoked role
await ProtocolAuthorization.verifyInvokedRole(
tenant,
incomingMessage,
protocol!,
contextId,
protocolDefinition,
messageStore,
);

// verify method invoked against the allowed actions
await ProtocolAuthorization.verifyAllowedActions(
tenant,
incomingMessage,
inboundMessageRuleSet,
[], // ancestor chain is not relevant to subscriptions
messageStore,
);
}

/**
* Performs protocol-based authorization against the incoming RecordsQuery message.
* @throws {Error} if authorization fails.
Expand Down Expand Up @@ -423,7 +465,7 @@ export class ProtocolAuthorization {
*/
private static async verifyInvokedRole(
tenant: string,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite,
protocolUri: string,
contextId: string | undefined,
protocolDefinition: ProtocolDefinition,
Expand Down Expand Up @@ -481,7 +523,7 @@ export class ProtocolAuthorization {
*/
private static async getActionsSeekingARuleMatch(
tenant: string,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite,
messageStore: MessageStore,
): Promise<ProtocolAction[]> {

Expand All @@ -495,6 +537,9 @@ export class ProtocolAuthorization {
case DwnMethodName.Read:
return [ProtocolAction.Read];

case DwnMethodName.Subscribe:
return [ProtocolAction.Subscribe];

case DwnMethodName.Write:
const incomingRecordsWrite = incomingMessage as RecordsWrite;
if (await incomingRecordsWrite.isInitialWrite()) {
Expand All @@ -519,7 +564,7 @@ export class ProtocolAuthorization {
*/
private static async verifyAllowedActions(
tenant: string,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsWrite,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite,
inboundMessageRuleSet: ProtocolRuleSet,
ancestorMessageChain: RecordsWriteMessage[],
messageStore: MessageStore,
Expand Down
36 changes: 35 additions & 1 deletion src/core/records-grant-authorization.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { MessageStore } from '../types/message-store.js';
import type { RecordsPermissionScope } from '../types/permissions-grant-descriptor.js';
import type { PermissionsGrantMessage, RecordsPermissionsGrantMessage } from '../types/permissions-types.js';
import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsWriteMessage } from '../types/records-types.js';
import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsSubscribeMessage, RecordsWriteMessage } from '../types/records-types.js';

import { GrantAuthorization } from './grant-authorization.js';
import { PermissionsConditionPublication } from '../types/permissions-grant-descriptor.js';
Expand Down Expand Up @@ -96,6 +96,40 @@ export class RecordsGrantAuthorization {
}
}

/**
* Authorizes the scope of a PermissionsGrant for RecordsSubscribe.
* @param messageStore Used to check if the grant has been revoked.
*/
public static async authorizeSubscribe(input: {
recordsSubscribeMessage: RecordsSubscribeMessage,
expectedGrantedToInGrant: string,
expectedGrantedForInGrant: string,
permissionsGrantMessage: PermissionsGrantMessage,
messageStore: MessageStore,
}): Promise<void> {
const {
recordsSubscribeMessage, expectedGrantedToInGrant, expectedGrantedForInGrant, permissionsGrantMessage, messageStore
} = input;

await GrantAuthorization.performBaseValidation({
incomingMessage: recordsSubscribeMessage,
expectedGrantedToInGrant,
expectedGrantedForInGrant,
permissionsGrantMessage,
messageStore
});

// If the grant specifies a protocol, the query must specify the same protocol.
const protocolInGrant = (permissionsGrantMessage.descriptor.scope as RecordsPermissionScope).protocol;
const protocolInSubscribe = recordsSubscribeMessage.descriptor.filter.protocol;
if (protocolInGrant !== undefined && protocolInSubscribe !== protocolInGrant) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationSubscribeProtocolScopeMismatch,
`Grant protocol scope ${protocolInGrant} does not match protocol in subscribe ${protocolInSubscribe}`
);
}
}

/**
* Authorizes the scope of a PermissionsGrant for RecordsDelete.
* @param messageStore Used to check if the grant has been revoked.
Expand Down
Loading