Skip to content
Merged
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: 6 additions & 0 deletions .changeset/big-lamps-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@learncard/types": patch
"@learncard/network-brain-service": patch
---

feat: Add Default Permissions to Boosts
100 changes: 99 additions & 1 deletion docs/core-concepts/credentials-and-data/boost-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ The Boost permission system is comprehensive, allowing fine-grained control over
* Directly assigned to profiles
* Inherited through the Boost hierarchy
* Granted when claiming a Boost
* Applied by default to all authenticated users via `defaultPermissions`

### Permission Properties <a href="#permission-properties" id="permission-properties"></a>

Expand Down Expand Up @@ -236,21 +237,26 @@ graph TD
admin["Admin"]
issuer["Issuer"]
viewer["Viewer"]
none["No Access"]
anyUser["Any Authenticated User"]
end

subgraph "Permission Types"
direct["Direct Permissions"]
inherited["Inherited Permissions"]
claim["Claim Permissions"]
default["Default Permissions"]
end

creator -->|"has"| direct
admin -->|"has"| direct
anyUser -->|"receives"| default

direct -->|"defines"| canEdit
direct -->|"defines"| canIssue
direct -->|"defines"| canManagePermissions
default -->|"grants"| canIssue
default -->|"grants"| canEdit
default -->|"grants"| canViewAnalytics

subgraph "Permission Propagation"
parent["Parent Boost Permissions"]
Expand All @@ -274,6 +280,98 @@ The system provides several endpoints for managing permissions:
4. **Get Boost Permissions**: Gets permissions for a specific profile
5. **Update Boost Permissions**: Updates permissions for a profile

### Default Permissions <a href="#default-permissions" id="default-permissions"></a>

`defaultPermissions` allow you to grant permissions to **all authenticated users** on a Boost without explicitly assigning roles. This is useful for creating "open" Boosts where anyone can perform certain actions.

#### Use Cases

* **Open Issuance**: Allow anyone to issue credentials from a Boost (e.g., community badges)
* **Collaborative Editing**: Enable any authenticated user to edit the Boost
* **Transparent Analytics**: Grant everyone access to view analytics

#### Setting Default Permissions

When creating or updating a Boost, you can specify `defaultPermissions`:

```typescript
// Create a Boost that anyone can issue
const boostUri = await learnCard.invoke.createBoost(credential, {
name: 'Community Badge',
defaultPermissions: {
canIssue: true, // Anyone can issue this Boost
canViewAnalytics: true, // Anyone can view analytics
},
});
```

#### Updating Default Permissions

Default permissions can be updated on published Boosts:

```typescript
// Add canEdit permission to existing Boost
await learnCard.invoke.updateBoost(boostUri, {
defaultPermissions: {
canIssue: true,
canEdit: true,
},
});
```

#### Supported Permissions

The following permissions can be set via `defaultPermissions`:

| Permission | Description |
| -------------------- | ------------------------------------------------ |
| canIssue | Allows any user to issue the Boost to recipients |
| canEdit | Allows any user to edit the Boost metadata |
| canRevoke | Allows any user to revoke issued credentials |
| canManagePermissions | Allows any user to manage Boost permissions |
| canViewAnalytics | Allows any user to view Boost analytics |

{% hint style="info" %}
**Permission Precedence**: Explicit role permissions are merged with default permissions. If a user has both an explicit role and default permissions apply, they receive the combined set of permissions.
{% endhint %}

#### Example: Community-Issued Badge

```typescript
// Create a Boost that anyone in the community can issue
const communityBadgeUri = await learnCard.invoke.createBoost(
{
'@context': [
'https://www.w3.org/2018/credentials/v1',
'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.1.json',
'https://ctx.learncard.com/boosts/1.0.3.json',
],
type: ['VerifiableCredential', 'OpenBadgeCredential', 'BoostCredential'],
name: 'Community Helper Badge',
issuer: 'did:web:community.example.com',
credentialSubject: {
type: ['AchievementSubject'],
achievement: {
type: ['Achievement'],
name: 'Community Helper',
description: 'Recognized for helping others in the community',
criteria: { narrative: 'Awarded by any community member' },
},
},
},
{
name: 'Community Helper Badge',
category: 'Social Badge',
defaultPermissions: {
canIssue: true, // Any authenticated user can issue this badge
},
}
);

// Now any authenticated user can issue this badge to others
await anyUser.invoke.sendBoost('recipient-profile-id', communityBadgeUri, signedCredential);
```

## Boost Queries <a href="#boost-queries" id="boost-queries"></a>

The system supports complex querying of Boosts with a flexible query language, allowing filtering by:
Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/create-a-boost.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ Boosts are a powerful way to manage credentialing at scale. From here, you can e

* **Retrieving Boost Recipients:** Use `learnCard.invoke.getPaginatedBoostRecipients(boostUri)` to see who has been issued a credential from this Boost.
* **Boost Permissions:** Control who can edit, issue, or manage your Boosts. (See Boost Permission Model).
* **Default Permissions:** Use `defaultPermissions` to create open Boosts that anyone can issue. (See [Default Permissions](../core-concepts/credentials-and-data/boost-credentials.md#default-permissions)).
* **Boost Hierarchies:** Organize Boosts into parent-child relationships. (See Boost Hierarchies).
* Customizing **Display Options** for your Boosts to make them visually appealing in wallets.
* **ConsentFlow Integration:** Link boosts to consent contracts for automatic routing. (See [ConsentFlow Overview](../core-concepts/consent-and-permissions/consentflow-overview.md)).
Expand Down
1 change: 1 addition & 0 deletions packages/learn-card-types/src/lcn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export const BoostValidator = z.object({
autoConnectRecipients: z.boolean().optional(),
meta: z.record(z.string(), z.any()).optional(),
claimPermissions: BoostPermissionsValidator.optional(),
defaultPermissions: BoostPermissionsValidator.optional(),
allowAnyoneToCreateChildren: z.boolean().optional(),
});
export type Boost = z.infer<typeof BoostValidator>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -645,137 +645,15 @@ export const canProfileCreateChildBoost = async (
};

export const canProfileEditBoost = async (profile: ProfileType, boost: BoostInstance) => {
const query = new QueryBuilder()
.match({ model: Boost, where: { id: boost.id }, identifier: 'targetBoost' })
.match({ model: Profile, where: { profileId: profile.profileId }, identifier: 'profile' })
.match({
optional: true,
related: [
{ identifier: 'targetBoost' },
{ ...Boost.getRelationshipByAlias('hasRole'), identifier: 'hasRole' },
{ identifier: 'profile' },
],
})
.match({ optional: true, literal: '(role:Role {id: hasRole.roleId})' })
.match({
optional: true,
related: [
{ identifier: 'profile' },
{
...Boost.getRelationshipByAlias('hasRole'),
identifier: 'parentHasRole',
direction: 'none',
},
{ identifier: 'parentBoost', model: Boost },
{
...Boost.getRelationshipByAlias('parentOf'),
direction: 'out',
maxHops: Infinity,
},
{ identifier: 'targetBoost' },
],
})
.match({ optional: true, literal: '(parentRole:Role {id: parentHasRole.roleId})' });

const result = await query
.return(
`
COALESCE(hasRole.canEdit, role.canEdit) AS directCanEdit,
COALESCE(parentHasRole.canEditChildren, parentRole.canEditChildren) AS parentCanEditChildren
`
)
.run();

return result.records.some(record => {
if (!record) return false;
const permissions = await getBoostPermissions(boost, profile);

const directCanEdit = record.get('directCanEdit');
const parentEditChildren = record.get('parentCanEditChildren');

if (!parentEditChildren || parentEditChildren === '{}') return Boolean(directCanEdit);

if (parentEditChildren === '*') return true;

try {
if (parentEditChildren && typeof parentEditChildren === 'string') {
return (
sift(JSON.parse(parentEditChildren))(boost.dataValues) || Boolean(directCanEdit)
);
}
} catch (error) {
console.error('Error trying to parse canEditChildren query!', error);
}

return Boolean(directCanEdit);
});
return Boolean(permissions.canEdit);
};

export const canProfileIssueBoost = async (profile: ProfileType, boost: BoostInstance) => {
const query = new QueryBuilder()
.match({ model: Boost, where: { id: boost.id }, identifier: 'targetBoost' })
.match({ model: Profile, where: { profileId: profile.profileId }, identifier: 'profile' })
.match({
optional: true,
related: [
{ identifier: 'targetBoost' },
{ ...Boost.getRelationshipByAlias('hasRole'), identifier: 'hasRole' },
{ identifier: 'profile' },
],
})
.match({ optional: true, literal: '(role:Role {id: hasRole.roleId})' })
.match({
optional: true,
related: [
{ identifier: 'profile' },
{
...Boost.getRelationshipByAlias('hasRole'),
identifier: 'parentHasRole',
direction: 'none',
},
{ identifier: 'parentBoost', model: Boost },
{
...Boost.getRelationshipByAlias('parentOf'),
direction: 'out',
maxHops: Infinity,
},
{ identifier: 'targetBoost' },
],
})
.match({ optional: true, literal: '(parentRole:Role {id: parentHasRole.roleId})' });
const permissions = await getBoostPermissions(boost, profile);

const result = await query
.return(
`
COALESCE(hasRole.canIssue, role.canIssue) AS directCanIssue,
COALESCE(parentHasRole.canIssueChildren, parentRole.canIssueChildren) AS parentCanIssueChildren
`
)
.run();

return result.records.some(record => {
if (!record) return false;

const directCanIssue = record.get('directCanIssue');
const parentCanIssueChildren = record.get('parentCanIssueChildren');

if (!parentCanIssueChildren || parentCanIssueChildren === '{}')
return Boolean(directCanIssue);

if (parentCanIssueChildren === '*') return true;

try {
if (parentCanIssueChildren && typeof parentCanIssueChildren === 'string') {
return (
sift(JSON.parse(parentCanIssueChildren))(boost.dataValues) ||
Boolean(directCanIssue)
);
}
} catch (error) {
console.error('Error trying to parse canIssueChildren query!', error);
}

return Boolean(directCanIssue);
});
return Boolean(permissions.canIssue);
};

export const getBoostPermissions = async (
Expand Down Expand Up @@ -813,14 +691,24 @@ export const getBoostPermissions = async (
{ identifier: 'targetBoost' },
],
})
.match({ optional: true, literal: '(parentRole:Role {id: parentHasRole.roleId})' });
.match({ optional: true, literal: '(parentRole:Role {id: parentHasRole.roleId})' })
// Match defaultRole for boost-level default permissions
.match({
optional: true,
related: [
{ identifier: 'targetBoost' },
Boost.getRelationshipByAlias('defaultRole'),
{ model: Role, identifier: 'defaultRole' },
],
});

const result = convertQueryResultToPropertiesObjectArray<{
role: BoostPermissions;
hasRole: BoostPermissions & { roleId: string };
parentRole: BoostPermissions;
parentHasRole: BoostPermissions & { roleId: string };
}>(await query.return('role, hasRole, parentRole, parentHasRole').run());
defaultRole: BoostPermissions;
}>(await query.return('role, hasRole, parentRole, parentHasRole, defaultRole').run());

if (
ensure &&
Expand All @@ -833,6 +721,10 @@ export const getBoostPermissions = async (
)
await giveProfileEmptyPermissions(profile, boost);

// Get defaultRole permissions as the base (if any)
const defaultRolePermissions = result[0]?.defaultRole ?? EMPTY_PERMISSIONS;
const basePermissions = { ...EMPTY_PERMISSIONS, ...defaultRolePermissions };

const directPermissions = result.reduce(
(permissions, { role, hasRole }) => {
const computedPermissions = { ...role, ...hasRole };
Expand Down Expand Up @@ -875,7 +767,7 @@ export const getBoostPermissions = async (

return permissions;
},
{ ...EMPTY_PERMISSIONS }
{ ...basePermissions }
);

return result.reduce((permissions, { parentRole, parentHasRole }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@ export const addClaimPermissionsForBoost = async (

await boost.relateTo({ alias: 'claimRole', where: { id: role.id } });
};

export const addDefaultPermissionsForBoost = async (
boost: BoostInstance,
permissions: BoostPermissions
): Promise<void> => {
const role = await createRole(permissions);

await boost.relateTo({ alias: 'defaultRole', where: { id: role.id } });
};
Loading
Loading