Skip to content

Commit 9a4e5ad

Browse files
authored
Merge pull request #566 from roboflow/renzo/ent-2118-add-a-roll-command-to-the-roboflow-api-key-cli
Add a roll command to the roboflow api-key CLI
2 parents a27fca3 + 49307f2 commit 9a4e5ad

4 files changed

Lines changed: 539 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## Unreleased
6+
7+
### Added
8+
9+
- `roboflow api-key roll <key_id>` revokes an API key and creates its replacement
10+
in one step, printing the new secret once. `--rotation-due-at` and `--owner`
11+
(repeatable) set the replacement's rotation schedule; omitted, the old key's
12+
schedule carries over. A protected key returns exit code 1 with a hint, like
13+
`revoke`; a key that was already rolled or revoked is not found (exit code 3).
14+
- `roboflow api-key create` and `update` accept `--rotation-due-at` and `--owner`
15+
(repeatable) to set a key's rotation schedule; `update` also takes
16+
`--clear-rotation-due-at` and `--clear-owners`.
17+
518
## 1.7.2
619

720
### Fixed

‎roboflow/adapters/rfapi.py‎

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2473,6 +2473,19 @@ def __repr__(self) -> str:
24732473
FULL_ACCESS = _FullAccess()
24742474

24752475

2476+
class _ClearValue:
2477+
"""Sentinel for "send JSON ``null``" on fields where ``None`` means "leave unchanged".
2478+
2479+
``update_api_key(..., rotation_due_at=CLEAR)`` clears the key's rotation due date.
2480+
"""
2481+
2482+
def __repr__(self) -> str:
2483+
return "CLEAR"
2484+
2485+
2486+
CLEAR = _ClearValue()
2487+
2488+
24762489
def list_api_keys(
24772490
api_key: str,
24782491
workspace_url: str,
@@ -2516,6 +2529,8 @@ def create_api_key(
25162529
folder_ids: Optional[List[str]] = None,
25172530
custom_metadata: Optional[Dict] = None,
25182531
protected: bool = False,
2532+
rotation_due_at: Optional[str] = None,
2533+
owners: Optional[List[str]] = None,
25192534
) -> dict:
25202535
"""POST /{workspace}/api-keys — create a new API key.
25212536
@@ -2524,8 +2539,11 @@ def create_api_key(
25242539
own scopes, so a full-access credential creates a full-access key. Pass a list
25252540
to scope the key (``role:<name>`` presets are accepted), ``[]`` for a key with
25262541
no abilities, or ``FULL_ACCESS`` to send an explicit ``null`` (unscoped/full
2527-
access). ``scopes``, ``folder_ids``, and ``custom_metadata`` require the
2528-
Advanced API Keys plan feature — the backend returns 403 if unavailable.
2542+
access). ``rotation_due_at`` (ISO date or date-time, UTC, in the future) and
2543+
``owners`` (member ids reminded before it; ``[]`` means everyone who can roll
2544+
the key) set the rotation schedule. ``scopes``, ``folder_ids``,
2545+
``custom_metadata``, ``rotation_due_at`` and ``owners`` require the Advanced
2546+
API Keys plan feature — the backend returns 403 if unavailable.
25292547
"""
25302548
body: Dict[str, Any] = {}
25312549
if name is not None:
@@ -2541,6 +2559,10 @@ def create_api_key(
25412559
body["customMetadata"] = custom_metadata
25422560
if protected:
25432561
body["protected"] = True
2562+
if rotation_due_at is not None:
2563+
body["rotationDueAt"] = rotation_due_at
2564+
if owners is not None:
2565+
body["owners"] = owners
25442566
response = http.post(f"{API_URL}/{workspace_url}/api-keys", params={"api_key": api_key}, json=body)
25452567
if not response.ok:
25462568
raise RoboflowError(response.text, status_code=response.status_code)
@@ -2551,21 +2573,24 @@ def update_api_key(api_key: str, workspace_url: str, key_id: str, **fields: Any)
25512573
"""PATCH /{workspace}/api-keys/{keyId} — update an existing API key.
25522574
25532575
Pass only the fields you want to change as keyword arguments:
2554-
``name``, ``scopes``, ``custom_metadata``, ``protected``, ``disabled``.
2576+
``name``, ``scopes``, ``custom_metadata``, ``protected``, ``disabled``,
2577+
``rotation_due_at``, ``owners``.
25552578
``None`` values are omitted (left unchanged). To send explicit values,
25562579
pass ``scopes=[]`` (no abilities), ``scopes=FULL_ACCESS`` (unscoped/full
2557-
access, serialized as ``null``), or ``custom_metadata={}`` (clear metadata).
2580+
access, serialized as ``null``), ``custom_metadata={}`` (clear metadata),
2581+
``rotation_due_at=CLEAR`` (clear the due date, serialized as ``null``) or
2582+
``owners=[]`` (remind everyone who can roll the key).
25582583
The API cannot unprotect a key (``protected=False`` → 403).
25592584
Disabling a protected key returns 409.
25602585
"""
25612586
encoded = quote(key_id, safe="")
25622587
# Canonical wire field is camelCase `customMetadata` (consistent with `folderIds`); callers may
25632588
# pass the Pythonic `custom_metadata` kwarg, which is normalized here.
2564-
wire_aliases = {"custom_metadata": "customMetadata"}
2589+
wire_aliases = {"custom_metadata": "customMetadata", "rotation_due_at": "rotationDueAt"}
25652590
body: Dict[str, Any] = {}
25662591
for k, v in fields.items():
25672592
wire_key = wire_aliases.get(k, k)
2568-
if v is FULL_ACCESS:
2593+
if v is FULL_ACCESS or v is CLEAR:
25692594
body[wire_key] = None
25702595
elif v is not None:
25712596
body[wire_key] = v
@@ -2592,3 +2617,36 @@ def revoke_api_key(api_key: str, workspace_url: str, key_id: str) -> dict:
25922617
if not response.ok:
25932618
raise RoboflowError(response.text, status_code=response.status_code)
25942619
return response.json()
2620+
2621+
2622+
def roll_api_key(
2623+
api_key: str,
2624+
workspace_url: str,
2625+
key_id: str,
2626+
rotation_due_at: Optional[str] = None,
2627+
owners: Optional[List[str]] = None,
2628+
) -> dict:
2629+
"""POST /{workspace}/api-keys/{keyId}/roll — revoke a key and mint its replacement.
2630+
2631+
The replacement keeps the old key's name, scopes, folders, metadata, protection
2632+
and disabled state, and its secret ``key`` value is returned only here (shown
2633+
once). ``rotation_due_at`` (ISO date or date-time, UTC, in the future) and
2634+
``owners`` set the replacement's rotation schedule; omitted, the old key's
2635+
schedule carries over. Both require the Advanced API Keys plan feature.
2636+
Rolling a protected or already rolled key returns 409; a credential that holds
2637+
less access than the target key gets 403.
2638+
"""
2639+
encoded = quote(key_id, safe="")
2640+
body: Dict[str, Any] = {}
2641+
if rotation_due_at is not None:
2642+
body["rotationDueAt"] = rotation_due_at
2643+
if owners is not None:
2644+
body["owners"] = owners
2645+
response = http.post(
2646+
f"{API_URL}/{workspace_url}/api-keys/{encoded}/roll",
2647+
params={"api_key": api_key},
2648+
json=body or None,
2649+
)
2650+
if not response.ok:
2651+
raise RoboflowError(response.text, status_code=response.status_code)
2652+
return response.json()

0 commit comments

Comments
 (0)