Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c896c78
accesskeys: add per-bucket scope to create, update, list, get
cneira Apr 13, 2026
cb75389
fix: scope JSON size limit, HTTP 200 for updates
cneira Apr 19, 2026
0ef966c
style: rename unused catch vars to _e for eslint compliance
cneira Apr 19, 2026
fad80bd
per-bucket scope: canonical envelope only, wildcard validation
cneira Apr 21, 2026
8f7fcf4
scope: delegate validation to shared scope-schema module
cneira Apr 21, 2026
f754961
accesskeys: cache-push scoped keys to mahi Redis on create/update/delete
cneira Apr 22, 2026
0c22b07
remove big O notation, adds no value
cneira Apr 23, 2026
880d1aa
fix: update package-lock.json for mahi 2.5.0
cneira Apr 23, 2026
a18b2f0
fix: pin mahi to node-mahi git repo commit 62c2486
cneira Apr 23, 2026
13a468a
fix: cachePush all keys on create, not just scoped keys
cneira Apr 24, 2026
de31e7b
deps: pin node-ufds to git commit 4a765f2 (v1.9.2, idleTimeout fix)
cneira Apr 25, 2026
0ce714f
deps: pin node-ufds to git commit f8ea6ad (v1.9.2, idleTimeout fix)
cneira Apr 25, 2026
23357a8
bump version
cneira Apr 25, 2026
2dd70d0
fix: regenerate package-lock.json for git-pinned node-ufds
cneira Apr 25, 2026
4ebd180
accesskeys: fix four review findings in scope endpoints
cneira Apr 27, 2026
6f3e2a3
Add bucket-scope parameter to documentation
cneira May 4, 2026
41a3169
Fix inaccurate comment
cneira May 4, 2026
8498be3
Update copyright
cneira May 4, 2026
3ebbd85
Fix horizontal white space
cneira May 6, 2026
965c478
fix horizontal white space
cneira May 7, 2026
1867efb
drop the misleading per-instance cache mutation from accesskeys
cneira May 12, 2026
f0f68bb
update node-mahi
cneira May 12, 2026
c4b61e7
bump minor version
cneira May 12, 2026
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
35 changes: 34 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ markdown2extras: tables, code-friendly
Copyright 2021 Joyent, Inc.
Copyright 2021 The University of Queensland
Copyright 2024 MNX Cloud, Inc.
Copyright 2025 Edgecast Cloud LLC.
Copyright 2026 Edgecast Cloud LLC.
-->


Expand Down Expand Up @@ -887,6 +887,10 @@ Note that a `Triton-Datacenter-Name` response header was added in 9.2.0.

The section describes API changes in CloudAPI versions.

## 9.21.1

- Add an optional bucket-scope parameter when creating an accesskey.

## 9.21.0

- Add ability to update an AccessKey's `status` and `description` field.
Expand Down Expand Up @@ -3362,6 +3366,35 @@ Generates a new access key id and secret.
----------- | -------- | ------------------------------------------------------------------
status | String | `Active`, `Inactive`, or `Expired` (optional, default is `Active`)
description | String | Description of Access Key (optional)
scope | Object. | Object describing permissions and bucket to which the accesskey will have access.

The json schema of this optional parameter is the following:
```json
{
"type": "object",
"required": ["version", "permissions"],
"properties": {
"version": { "const": 1 },
"permissions": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"type": "object",
"required": ["bucket", "level"],
"properties": {
"bucket": {
"type": "string",
"maxLength": 63,
"pattern": "^(\\*|[a-z0-9][a-z0-9.\\-]*\\*?)$"
},
"level": { "enum": ["read", "readwrite", "full"] }
}
}
}
}
}
```

### Returns

Expand Down
132 changes: 121 additions & 11 deletions lib/endpoints/accesskeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

/*
* Copyright 2020 Joyent, Inc.
* Copyright 2025 Edgecast Cloud LLC.
* Copyright 2026 Edgecast Cloud LLC.
*/


Expand All @@ -17,6 +17,7 @@ var assert = require('assert-plus');
var restify = require('restify');

var resources = require('../resources');
var scopeSchema = require('mahi').scopeSchema;


var sprintf = util.format;
Expand All @@ -31,6 +32,30 @@ var InternalError = restify.InternalError;
*/
var MAX_KEYS = 100;

/**
* @brief Validate a scope parameter from the request
*
* Delegates to the canonical scope schema module in node-mahi. Accepts only the
* canonical envelope: {"version":1,"permissions":[{bucket,level},...]}
*
* @param {Object} input - Scope envelope object
* @return {Object} {valid: bool, scope: string|null,
* error: string|null}
*/
function validateScope(input) {
return scopeSchema.validateScope(input);
}


/**
* @brief Translate UFDS access key to API response format
*
* Maps internal UFDS attributes to the external API representation. Includes
* the accesskeyscope field as a parsed JSON object when present.
*
* @param {Object} accesskey - UFDS access key entry
* @return {Object} Translated access key for response
*/
function translateAccessKey(accesskey) {
if (!accesskey) {
return {};
Expand Down Expand Up @@ -65,6 +90,20 @@ function translateAccessKey(accesskey) {
translated.expiration = null;
}

/*
* Return bucket scope as a parsed JSON object so callers can inspect
* permissions directly. When absent the key is unrestricted.
*/
if (accesskey.accesskeyscope) {
try {
translated.scope = JSON.parse(accesskey.accesskeyscope);
} catch (_e) {
translated.scope = null;
}
} else {
translated.scope = null;
}

return translated;
}

Expand Down Expand Up @@ -107,6 +146,22 @@ function create(req, res, next) {
return;
}

/*
* Per-bucket access key scoping. Scope must be the canonical envelope:
* {"version":1,"permissions":[{bucket,level},...]}
*
* Absent/null scope means unrestricted.
*/
if (req.params.scope !== undefined &&
Comment thread
travispaul marked this conversation as resolved.
req.params.scope !== null) {
var result = validateScope(req.params.scope);
if (!result.valid) {
next(new InvalidArgumentError(result.error));
return;
}
params.accesskeyscope = result.scope;
}

try {
vasync.waterfall([

Expand Down Expand Up @@ -155,6 +210,16 @@ function create(req, res, next) {

var accesskeysecret = accesskey.accesskeysecret;

/*
* UFDS is authoritative. On a Redis miss, the
* mahi sigv4 read-through resolves the new key
* directly from UFDS on every authcache
* instance — that is what makes the new key
* usable on the first signed request without
* waiting for the replicator. We do not write
* to mahi from here.
*/

accesskey = translateAccessKey(accesskey);

// Only return the accesskeysecret on creation
Expand Down Expand Up @@ -317,9 +382,24 @@ function del(req, res, next) {
return;
}

/*
* UFDS is authoritative. Each authcache instance has
* its own mahi-replicator that polls UFDS and applies
* the deletion to local Redis on its own schedule. On
* a Redis miss the sigv4 read-through resolves the
* current state from UFDS directly. We do not call
* mahi from here: per-instance cache mutation from
* cloudapi was a misleading optimisation — it only
* ever reached the single mahi instance cloudapi
* could resolve by name, never the Manta authcache
* pool that serves the S3 data plane. The replicator
* is the consistency mechanism for every Redis
* holding this key.
*/
log.debug('DELETE %s -> ok', req.path());
res.send(204);
next();
return;
});
} catch (e) {
log.error({err: e}, 'delete accesskey exception');
Expand Down Expand Up @@ -354,6 +434,26 @@ function update(req, res, next) {
params.description = req.params.description;
}

/*
* Update bucket scope. An empty string or null removes the scope (makes
* the key unrestricted again). A non-empty value replaces the scope.
*/
if (req.params.scope !== undefined) {
if (req.params.scope === '' ||
req.params.scope === null) {
/* Remove scope: set null so UFDS deletes attr */
params.accesskeyscope = null;
} else {
var scopeResult = validateScope(req.params.scope);
if (!scopeResult.valid) {
next(new InvalidArgumentError(
scopeResult.error));
return;
}
params.accesskeyscope = scopeResult.scope;
}
}

// Make it clear that credential type and expiration cannot be changed.
if (req.params.credentialtype) {
next(new ForbiddenError('credentialtype cannot be set via CloudAPI'));
Expand All @@ -374,35 +474,45 @@ function update(req, res, next) {
return;
}

/*
* UFDS is authoritative. Each authcache instance has
* its own mahi-replicator that polls UFDS and
* applies the update to local Redis on its own
* schedule. We do not call mahi from here — see the
* matching comment in del() above.
*/
accesskey = translateAccessKey(accesskey);

if (account) {
res.header('Location',
sprintf('/%s/users/%s/accesskeys/%s',
login,
sprintf('/%s/users/%s/accesskeys/%s', login,
user,
encodeURIComponent(accesskey.accesskeyid)));
encodeURIComponent(
accesskey.accesskeyid)));
} else {
res.header('Location',
sprintf('/%s/accesskeys/%s',
login,
encodeURIComponent(accesskey.accesskeyid)));
sprintf('/%s/accesskeys/%s', login,
encodeURIComponent(
accesskey.accesskeyid)));
}

if (req.headers['role-tag'] || req.activeRoles) {
// The resource we want to save is the individual one we've
// just created, not the collection URI:
/*
* The resource we want to save is the
* individual one we've just updated, not the
* collection URI.
*/
req.resourcename = req.resourcename + '/' +
accesskey.accesskeyid;
req.resource = {
req.resource = {
name: req.resourcename,
account: req.account.uuid,
roles: []
};
}

log.debug('POST %s => %j', req.path(), accesskey);
res.send(201, accesskey);
res.send(200, accesskey);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oof, thanks for fixing that.

Could you add this change as an entry to the version changelog here: https://github.com/TritonDataCenter/sdc-cloudapi/blob/master/docs/index.md#versions

Along with an entry for the new scope prop?

Also separately, could you add details about the new scope prop to the relevant endpoint docs here: https://github.com/TritonDataCenter/sdc-cloudapi/blob/master/docs/index.md#accesskeys

next();
return;
});
Expand Down
Loading