Skip to content

Commit

Permalink
Enforce protocol defined RecordsWrite tags (#711)
Browse files Browse the repository at this point in the history
This will allow protocol defined tags to adhere to schema rules by
adding a $tags property under the protocolPath definition..
```typescript
const protocolDefinition: ProtocolDefinition = {
  protocol  : 'http://example.com/protocol/withTags',
  published : true,
  types     : {
    foo: {}
  },
  structure: {
    foo: {
      $tags: {
      // tag definitions go here
      }
    }
  },
};
```

Tags are defined as objects within the $tag object. Each property is the
tag name, and the object is a JSON Schema definition.

The following definition requires a `draft` boolean tag to be present as
well as a `status` tag which can only have the value of `success` or
`failure`.
Additional undefined tags are allowed without any validation due to
`allowUndefinedTags` set to `true`, this is set to `false` by default.

```typescript
$tags: {
  allowUndefinedTags: true,
  requiredTags: ['status', 'draft'],
  status: {
    type: 'string',
    enum: ['success', 'failure']
  },
  draft: {
    type: 'boolean'
  }
}
```

Types:
✅  string
✅  number
✅  array
✅  integer

Value-testing props:
✅ minLength
✅ maxLength
✅ minimum
✅ exclusiveMinimum
✅ maximum
✅ exclusiveMaximum
✅ minItems
✅ maxItems
✅ minContains
✅ maxContains
✅ uniqueItems

Array evaluation props:
✅  contains (bound to same restricted list value-testing props above)
✅  items (bound to same restricted list value-testing props above)

---------

Co-authored-by: Henry Tsai <[email protected]>
  • Loading branch information
LiranCohen and thehenrytsai authored Apr 24, 2024
1 parent cf50d0a commit 4bdd0c6
Show file tree
Hide file tree
Showing 7 changed files with 2,108 additions and 5 deletions.
54 changes: 53 additions & 1 deletion json-schemas/interface-methods/protocol-rule-set.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,62 @@
"minimum": 0
}
}
},
"$tags": {
"type": "object",
"minProperties": 1,
"properties": {
"$requiredTags": {
"type": "array",
"items": {
"type": "string"
}
},
"$allowUndefinedTags": {
"type": "boolean"
}
},
"patternProperties": {
"^(?!\\$requiredTags$|\\$allowUndefinedTags$).*$": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"enum": ["string", "number", "integer", "boolean", "array"]
},
"items": {
"type": "object",
"properties": {
"type": {
"enum": ["string", "number", "integer"]
}
},
"patternProperties": {
"^(enum|minimum|maximum|exclusiveMinimum|exclusiveMaximum|minLength|maxLength)$": {}
}
},
"contains": {
"type": "object",
"properties": {
"type": {
"enum": ["string", "number", "integer"]
}
},
"patternProperties": {
"^(enum|minimum|maximum|exclusiveMinimum|exclusiveMaximum|minLength|maxLength)$": {}
}
}
},
"patternProperties": {
"^(enum|minimum|maximum|exclusiveMinimum|exclusiveMaximum|minLength|maxLength|minItems|maxItems|uniqueItems|minContains|maxContains)$": {
}
}
}
}
}
},
"patternProperties": {
"^[^$].*": {
"^[^$].*$": {
"$ref": "https://identity.foundation/dwn/json-schemas/protocol-rule-set.json"
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/dwn-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export enum DwnErrorCode {
ProtocolAuthorizationProtocolNotFound = 'ProtocolAuthorizationProtocolNotFound',
ProtocolAuthorizationQueryWithoutRole = 'ProtocolAuthorizationQueryWithoutRole',
ProtocolAuthorizationRoleMissingRecipient = 'ProtocolAuthorizationRoleMissingRecipient',
ProtocolAuthorizationTagsInvalidSchema = 'ProtocolAuthorizationTagsInvalidSchema',
ProtocolsConfigureDuplicateActorInRuleSet = 'ProtocolsConfigureDuplicateActorInRuleSet',
ProtocolsConfigureDuplicateRoleInRuleSet = 'ProtocolsConfigureDuplicateRoleInRuleSet',
ProtocolsConfigureInvalidSize = 'ProtocolsConfigureInvalidSize',
Expand All @@ -81,6 +82,7 @@ export enum DwnErrorCode {
ProtocolsConfigureInvalidActionUpdateWithoutCreate = 'ProtocolsConfigureInvalidActionUpdateWithoutCreate',
ProtocolsConfigureInvalidRecipientOfAction = 'ProtocolsConfigureInvalidRecipientOfAction',
ProtocolsConfigureInvalidRuleSetRecordType = 'ProtocolsConfigureInvalidRuleSetRecordType',
ProtocolsConfigureInvalidTagSchema = 'ProtocolsConfigureInvalidTagSchema',
ProtocolsConfigureQueryNotAllowed = 'ProtocolsConfigureQueryNotAllowed',
ProtocolsConfigureRecordNestingDepthExceeded = 'ProtocolsConfigureRecordNestingDepthExceeded',
ProtocolsConfigureRoleDoesNotExistAtGivenPath = 'ProtocolsConfigureRoleDoesNotExistAtGivenPath',
Expand Down
39 changes: 39 additions & 0 deletions src/core/protocol-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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';

import Ajv from 'ajv/dist/2020.js';
import { FilterUtility } from '../utils/filter.js';
import { PermissionsProtocol } from '../protocols/permissions.js';
import { Records } from '../utils/records.js';
Expand Down Expand Up @@ -62,6 +63,9 @@ export class ProtocolAuthorization {

// Verify size limit
ProtocolAuthorization.verifySizeLimit(incomingMessage, ruleSet);

// Verify protocol tags
ProtocolAuthorization.verifyTagsIfNeeded(incomingMessage, ruleSet);
}

/**
Expand Down Expand Up @@ -687,6 +691,41 @@ export class ProtocolAuthorization {
}
}

private static verifyTagsIfNeeded(
incomingMessage: RecordsWrite,
ruleSet: ProtocolRuleSet
): void {
if (ruleSet.$tags !== undefined) {
const { tags = {}, protocol, protocolPath } = incomingMessage.message.descriptor;

const { $allowUndefinedTags, $requiredTags, ...properties } = ruleSet.$tags;

// if $allowUndefinedTags is set to false and there are properties not defined in the schema, an error is thrown
const additionalProperties = $allowUndefinedTags || false;

// if $requiredTags is set, all required tags must be present
const required = $requiredTags || [];

const ajv = new Ajv.default();
const compiledTags = ajv.compile({
type: 'object',
properties,
required,
additionalProperties,
});

const validSchema = compiledTags(tags);
if (!validSchema) {
// the `dataVar` is used to add a qualifier to the error message.
// For example. If the error is related to a tag `status` in a protocol `https://example.protocol` with the protocolPath `example/path`
// the error would be described as `https://example.protocol/example/path/$tags/status'
// without this decorator it would show up as `data/status` which may be confusing.
const schemaError = ajv.errorsText(compiledTags.errors, { dataVar: `${protocol}/${protocolPath}/$tags` });
throw new DwnError(DwnErrorCode.ProtocolAuthorizationTagsInvalidSchema, `tags schema validation error: ${schemaError}`);
}
}
}

/**
* If the given RecordsWrite is not a role record, this method does nothing and succeeds immediately.
*
Expand Down
17 changes: 16 additions & 1 deletion src/interfaces/protocols-configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Signer } from '../types/signer.js';
import type { ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureDescriptor, ProtocolsConfigureMessage } from '../types/protocols-types.js';

import { AbstractMessage } from '../core/abstract-message.js';
import Ajv from 'ajv/dist/2020.js';
import { Message } from '../core/message.js';
import { Time } from '../utils/time.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
Expand Down Expand Up @@ -129,7 +130,6 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
private static validateRuleSetRecursively(
input: { ruleSet: ProtocolRuleSet, ruleSetProtocolPath: string, recordTypes: string[], roles: string[] }
): void {

const { ruleSet, ruleSetProtocolPath, recordTypes, roles } = input;

// Validate $actions in the rule set
Expand All @@ -144,6 +144,21 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
}
}

if (ruleSet.$tags) {
const ajv = new Ajv.default();
const { $allowUndefinedTags, $requiredTags, ...tagProperties } = ruleSet.$tags;

// we validate each tag's expected schema to ensure it is a valid JSON schema
for (const tag in tagProperties) {
const tagSchemaDefinition = tagProperties[tag];

if (!ajv.validateSchema(tagSchemaDefinition)) {
const schemaError = ajv.errorsText(ajv.errors, { dataVar: `${ruleSetProtocolPath}/$tags/${tag}` });
throw new DwnError(DwnErrorCode.ProtocolsConfigureInvalidTagSchema, `tags schema validation error: ${schemaError}`);
}
}
}

// validate each action rule
const actionRules = ruleSet.$actions ?? [];
for (let i = 0; i < actionRules.length; i++) {
Expand Down
12 changes: 12 additions & 0 deletions src/types/protocols-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ export type ProtocolRuleSet = {
max?: number
}

/**
* If $tags is set, the record must conform to the tag rules.
*/
$tags?: {
/** array of required tags */
$requiredTags?: string[],
/** allow properties other than those explicitly listed. defaults to false */
$allowUndefinedTags?: boolean;

[key: string]: any;
}

// JSON Schema verifies that properties other than properties prefixed with $ will actually have type ProtocolRuleSet
[key: string]: any;
};
Expand Down
1 change: 0 additions & 1 deletion src/utils/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,6 @@ export class Records {
const tagValues:Filter = {};
for (const property in tags) {
const value = tags[property];

tagValues[`tag.${property}`] = this.isStartsWithFilter(value) ? FilterUtility.constructPrefixFilterAsRangeFilter(value.startsWith) : value;
}
return tagValues;
Expand Down
Loading

0 comments on commit 4bdd0c6

Please sign in to comment.