Skip to content

Commit e1c6360

Browse files
Add secretary delegation support with relative namespace UI.
Fix secretary write-access checks and document the feature so delegated accounts can publish on behalf of an owner. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7f0a63a commit e1c6360

12 files changed

Lines changed: 321 additions & 29 deletions

File tree

docs/guides/secretary-guide.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Secretary Guide
2+
3+
## About this guide
4+
5+
This guide explains how to delegate write access to another Databus account (a **secretary**) and how to publish or edit resources on someone else's behalf.
6+
7+
## What is a secretary?
8+
9+
A secretary is another Databus account that you authorize to write in your namespace. You remain the owner; the secretary authenticates as themselves and tells the server which account they are acting for.
10+
11+
Typical use cases:
12+
13+
- A team member publishes datasets under an organization account
14+
- A CI pipeline uses its own API key but publishes into a shared account
15+
- A curator manages resources for multiple accounts they do not own
16+
17+
## Part 1: Declaring secretaries
18+
19+
Only the account owner can edit the secretary list.
20+
21+
### Using the web interface
22+
23+
1. Log in and open your account profile (`/<your-account>`) or **User Settings**.
24+
2. Find the **Secretaries** section.
25+
3. Click **Add Secretary**.
26+
4. Enter the secretary's account name (e.g. `dbpedia`).
27+
5. Optionally add **Write Access** paths (relative to your account, e.g. `datasets`) to document which parts of your namespace the secretary may use. The UI shows your account base URL as a fixed prefix.
28+
6. Save the account.
29+
30+
The secretary list is stored in your account metadata as JSON-LD (`databus#secretary`).
31+
32+
### Using the API
33+
34+
Update your account via `POST /api/account/update` (authenticated as the owner):
35+
36+
```http
37+
POST /api/account/update
38+
x-api-key: <your-api-key>
39+
Content-Type: application/json
40+
41+
{
42+
"accountName": "myorg",
43+
"label": "My Organization",
44+
"status": "Publishing open data.",
45+
"imageUrl": null,
46+
"secretaries": [
47+
{
48+
"accountName": "alice",
49+
"hasWriteAccessTo": [
50+
"https://databus.example.org/myorg/datasets"
51+
]
52+
}
53+
]
54+
}
55+
```
56+
57+
Each secretary entry has:
58+
59+
| Field | Description |
60+
|-------|-------------|
61+
| `accountName` | Account name of the secretary |
62+
| `hasWriteAccessTo` | Optional list of namespace paths, stored as absolute IRIs (metadata only; see note below) |
63+
64+
In the web UI you enter the path relative to your account (e.g. `datasets`). On save it is stored as a full URI (`https://databus.example.org/myorg/datasets`). Via the API, send absolute IRIs directly.
65+
66+
## Part 2: Acting as a secretary
67+
68+
Authenticate as **your own** account (OIDC session or `x-api-key`). Add the `x-on-behalf-of` header with the **full account URI** of the owner:
69+
70+
```http
71+
x-on-behalf-of: https://databus.example.org/myorg
72+
```
73+
74+
Replace `https://databus.example.org` with your Databus instance's `DATABUS_RESOURCE_BASE_URL`.
75+
76+
### Publish data on behalf of another account
77+
78+
```http
79+
POST /api/register
80+
x-api-key: <your-api-key>
81+
x-on-behalf-of: https://databus.example.org/myorg
82+
Content-Type: application/ld+json
83+
84+
{
85+
"@context": "https://databus.example.org/context.jsonld",
86+
"@graph": [
87+
{
88+
"@id": "https://databus.example.org/myorg/datasets/example",
89+
"@type": "Group",
90+
...
91+
}
92+
]
93+
}
94+
```
95+
96+
All resource URIs in the payload must live under the delegated account namespace (`/myorg/...`).
97+
98+
### Delete a collection on behalf of another account
99+
100+
```http
101+
DELETE /myorg/collections/my-collection
102+
x-api-key: <your-api-key>
103+
x-on-behalf-of: https://databus.example.org/myorg
104+
```
105+
106+
### curl example
107+
108+
```bash
109+
curl -X POST "https://databus.example.org/api/register" \
110+
-H "x-api-key: YOUR_API_KEY" \
111+
-H "x-on-behalf-of: https://databus.example.org/myorg" \
112+
-H "Content-Type: application/ld+json" \
113+
-d @publish.jsonld
114+
```
115+
116+
## How authorization works
117+
118+
When you send `x-on-behalf-of`, the server:
119+
120+
1. Checks whether you already own the target account — if yes, allows the request.
121+
2. Otherwise fetches the target account's JSON-LD profile.
122+
3. Reads the `databus#secretary` list and checks whether your authenticated account is listed.
123+
4. Returns `403` if you are not listed.
124+
125+
You never share your API key or password with the owner. The owner only adds your account name to their secretary list.
126+
127+
## Important notes
128+
129+
- **`x-on-behalf-of` must be the full account URI**, not just the account name. Example: `https://databus.dbpedia.org/dbpedia`, not `dbpedia`.
130+
- **Write Access namespaces** (`hasWriteAccessTo`) are stored as absolute IRIs. The UI accepts relative paths under your account and expands them on save. Prefix matching is the intended model (a group IRI covers artifacts and versions under it), but the server does not enforce these IRIs yet.
131+
- **Only owners** can add or remove secretaries via `/api/account/update`.
132+
- Secretary access applies to write operations that check authorization (e.g. `/api/register`, collection delete). Read access is unchanged.
133+
134+
## Related guides
135+
136+
- [Publish Guide](publish-guide.md)
137+
- [API usage](../usage/api/README.md)
138+
- [Account model](../account.md)

public/css/website.css

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/css/website.css.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/css/website.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,16 @@ databus-icon {
13561356
border-radius: 4px 0px 0px 4px;
13571357
}
13581358

1359+
.databus-uri-prefix.button.is-static {
1360+
max-width: 55%;
1361+
overflow: hidden;
1362+
text-overflow: ellipsis;
1363+
white-space: nowrap;
1364+
color: hsl(0, 0%, 62%);
1365+
font-family: monospace;
1366+
font-size: 0.9em;
1367+
}
1368+
13591369
.error-notification-box {
13601370
background-color: #fdf2f2; /* very light red/pink background */
13611371
border-left: 3px solid #f14668; /* Bulma 'danger' color */

public/dist/main.js

Lines changed: 23 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/js/page-controller/profile-controller.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,10 @@ function ProfileController($scope, $http) {
288288
account.label = $scope.editData.label;
289289
account.status = $scope.editData.about;
290290
account.imageUrl = $scope.editData.imageUrl;
291-
account.secretaries = $scope.editData.secretaries;
291+
account.secretaries = DatabusUtils.secretariesForSave(
292+
$scope.editData.secretaries,
293+
account.accountName
294+
);
292295

293296

294297
try {
@@ -312,11 +315,20 @@ function ProfileController($scope, $http) {
312315
$scope.modsSettings.searchExtensionURI = "";
313316
$scope.modsSettings.searchExtensionAdapter = $scope.adapters[0];
314317

318+
$scope.getWriteAccessPrefix = function (accountName) {
319+
return DatabusUtils.getAccountNamespacePrefix(accountName);
320+
};
321+
322+
function initEditData() {
323+
var copy = DatabusUtils.createCleanCopy($scope.account);
324+
copy.secretaries = DatabusUtils.secretariesForEdit(copy.secretaries, copy.accountName);
325+
return copy;
326+
}
315327

316-
$scope.editData = DatabusUtils.createCleanCopy($scope.account);
328+
$scope.editData = initEditData();
317329

318330
$scope.resetEdits = function () {
319-
$scope.editData = DatabusUtils.createCleanCopy($scope.account);
331+
$scope.editData = initEditData();
320332
}
321333

322334
}

public/js/page-controller/user-settings-controller.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ function UserSettingsController($scope, $http, $sce, $location) {
7676
account.secretaries.push(secretary);
7777
}
7878

79+
account.secretaries = DatabusUtils.secretariesForEdit(account.secretaries, account.accountName);
80+
7981
})
8082
.catch(function (error) {
8183
// Handle error and set loading to false
@@ -111,7 +113,9 @@ function UserSettingsController($scope, $http, $sce, $location) {
111113
// Button click handler to save account
112114
$scope.saveAccount = async function (account) {
113115
try {
114-
await $http.post(`/api/account/update`, account);
116+
var payload = DatabusUtils.createCleanCopy(account);
117+
payload.secretaries = DatabusUtils.secretariesForSave(payload.secretaries, account.accountName);
118+
await $http.post(`/api/account/update`, payload);
115119
DatabusAlert.alert($scope, true, "Account saved.");
116120

117121
} catch (err) {
@@ -121,6 +125,10 @@ function UserSettingsController($scope, $http, $sce, $location) {
121125

122126
};
123127

128+
$scope.getWriteAccessPrefix = function (accountName) {
129+
return DatabusUtils.getAccountNamespacePrefix(accountName);
130+
};
131+
124132
// Button click handler to delete account
125133
$scope.deleteAccount = async function (account) {
126134
try {

public/js/utils/databus-utils.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,87 @@ class DatabusUtils {
275275
return data;
276276
}
277277

278+
static getAccountNamespacePrefix(accountName) {
279+
return `${DATABUS_RESOURCE_BASE_URL}/${accountName}/`;
280+
}
281+
282+
static toRelativeWriteAccessUri(absoluteUri, accountName) {
283+
if (absoluteUri == null || absoluteUri === '') {
284+
return '';
285+
}
286+
287+
const prefix = DatabusUtils.getAccountNamespacePrefix(accountName);
288+
if (absoluteUri.startsWith(prefix)) {
289+
return absoluteUri.slice(prefix.length);
290+
}
291+
292+
const accountUri = `${DATABUS_RESOURCE_BASE_URL}/${accountName}`;
293+
if (absoluteUri === accountUri) {
294+
return '';
295+
}
296+
297+
const basePrefix = `${DATABUS_RESOURCE_BASE_URL}/`;
298+
if (absoluteUri.startsWith(basePrefix)) {
299+
const remainder = absoluteUri.slice(basePrefix.length);
300+
const accountPrefix = `${accountName}/`;
301+
if (remainder.startsWith(accountPrefix)) {
302+
return remainder.slice(accountPrefix.length);
303+
}
304+
if (remainder === accountName) {
305+
return '';
306+
}
307+
}
308+
309+
return absoluteUri;
310+
}
311+
312+
static toAbsoluteWriteAccessUri(relativeUri, accountName) {
313+
if (relativeUri == null) {
314+
return null;
315+
}
316+
317+
const trimmed = relativeUri.trim();
318+
if (trimmed === '') {
319+
return null;
320+
}
321+
322+
return DatabusUtils.getAccountNamespacePrefix(accountName) + trimmed.replace(/^\/+/, '');
323+
}
324+
325+
static secretariesForEdit(secretaries, accountName) {
326+
if (secretaries == null) {
327+
return [];
328+
}
329+
330+
return secretaries.map(function (secretary) {
331+
return {
332+
accountName: secretary.accountName,
333+
hasWriteAccessTo: (secretary.hasWriteAccessTo || []).map(function (uri) {
334+
return DatabusUtils.toRelativeWriteAccessUri(uri, accountName);
335+
})
336+
};
337+
});
338+
}
339+
340+
static secretariesForSave(secretaries, accountName) {
341+
if (secretaries == null) {
342+
return [];
343+
}
344+
345+
return secretaries.map(function (secretary) {
346+
return {
347+
accountName: secretary.accountName,
348+
hasWriteAccessTo: (secretary.hasWriteAccessTo || [])
349+
.map(function (uri) {
350+
return DatabusUtils.toAbsoluteWriteAccessUri(uri, accountName);
351+
})
352+
.filter(function (uri) {
353+
return uri != null;
354+
})
355+
};
356+
});
357+
}
358+
278359
static lineCount(text) {
279360
return (text.match(/^\s*\S/gm) || "").length
280361
}
@@ -531,4 +612,14 @@ class DatabusUtils {
531612

532613
}
533614

615+
if (typeof process !== 'undefined' && require.main === module) {
616+
global.DATABUS_RESOURCE_BASE_URL = 'https://databus.example.org';
617+
console.assert(
618+
DatabusUtils.toRelativeWriteAccessUri('https://databus.example.org/myorg/datasets', 'myorg') === 'datasets'
619+
);
620+
console.assert(
621+
DatabusUtils.toAbsoluteWriteAccessUri('datasets', 'myorg') === 'https://databus.example.org/myorg/datasets'
622+
);
623+
}
624+
534625
module.exports = DatabusUtils;

public/templates/profile.ejs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,13 @@
6060
No secretaries defined.
6161
</div>
6262

63-
<div ng-repeat="secretary in editData.secretaries track by $index" class="settings-entity">
63+
<div ng-repeat="(secIndex, secretary) in editData.secretaries track by secIndex" class="settings-entity">
6464
<div style="width: 100%;">
6565
<div class="field">
6666
<div
6767
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5em;">
6868
<label class="label">Secretary</label>
69-
<a ng-click="removeSecretary(account, $index)" class="button is-error">
69+
<a ng-click="removeSecretary(account, secIndex)" class="button is-error">
7070
Delete
7171
</a>
7272
</div>
@@ -78,19 +78,22 @@
7878

7979
<div class="field" style="margin-bottom: 0.5em;">
8080
<label class="label">Write Access</label>
81-
<div ng-repeat="ns in secretary.hasWriteAccessTo track by $index" class="field has-addons mb-2">
81+
<div ng-repeat="(nsIndex, ns) in secretary.hasWriteAccessTo track by nsIndex" class="field has-addons mb-2">
82+
<div class="control">
83+
<span class="button is-static databus-uri-prefix">{{ getWriteAccessPrefix(editData.accountName) }}</span>
84+
</div>
8285
<div class="control is-expanded">
83-
<input class="input" type="text" placeholder="Namespace IRI"
84-
ng-model="secretary.hasWriteAccessTo[$index]">
86+
<input class="input" type="text" placeholder="e.g. datasets"
87+
ng-model="secretary.hasWriteAccessTo[nsIndex]">
8588
</div>
8689
<div class="control">
87-
<button class="button is-danger" ng-click="removeNamespace(account, $index, $index)">
90+
<button class="button is-danger" ng-click="removeNamespace(account, secIndex, nsIndex)">
8891
<span class="icon"><i class="fas fa-times"></i></span>
8992
</button>
9093
</div>
9194
</div>
9295

93-
<button class="button" ng-click="addNamespace(account, $index)">
96+
<button class="button" ng-click="addNamespace(account, secIndex)">
9497
<span class="icon"><i class="fas fa-plus"></i></span>
9598
<span>Add Namespace</span>
9699
</button>

0 commit comments

Comments
 (0)