Skip to content

feat: standing grants: one tap for a repeating job, built like an API key (end date, rotating token, alarms) #110

Closed

Description

@groupsky

Why

A job that runs again and again (daily, weekly) needs the same secret each run. A tap for each run defeats the point of a timer. A "never ask" rule (DESIGN.md decision 3 B) works, but it is a config edit, it has no end date, and little in it tells a theft apart from a normal use.

#108 covers one run planned ahead. This issue covers a job that repeats: one tap, then the job runs on its schedule for a limited time, without taps.

Treat such a grant like an API key. Good practice for API keys and similar credentials, checked on 2026-10-27:

Idea (decide the design when the work starts)

  1. One tap makes it. The message shows: the client, the job name (claimed, until feat: job identity: tokens signed by a job runner, so rules and grants can name one job #109), the secrets, the schedule (for example "daily, window 03:00 to 05:00"), the uses per window (default 1), and the end date. Approve, Deny.
  2. Narrow. Only the named secrets, only from the named client, only inside the windows. A secret allows standing grants only when its rule says so (standing = true; off by default).
  3. An end date is a must. The longest end date is set in the config (default 90 days). A Telegram warning 7 days before the end. Renewal is one tap and makes a new grant, so both grants work for a short time.
    Each window has a maximum length too (config; for example 8 hours, the longest window of section 8).
  4. A grant token for the job. The broker gives the client a random token once, after the tap, with a prefix (for example nkg_) and a checksum. The broker keeps only its hash. The token works only with the client's mTLS key.
  5. The token rotates on each use. Each use returns a new token. The client saves the new token before it uses the secret, so a crash does not lose it. If an old token comes back, the broker pauses the grant and sends an alarm. To see that a token is old, the broker keeps the hashes of old tokens (or a counter) until the grant ends.
  6. One use per window (or the set number). A second request in the same window is denied, the grant is paused, and an alarm goes out. Outside the windows every request is denied, with no ask and with an alarm.
  7. A missed window raises an alarm. A window that ends with no use sends an alarm, not only a line in the summary. This is the only sign when someone uses the token and also stops the job.
  8. Visible. Each use sends the silent notice, as a "never ask" use does (section 12). If the notice cannot be sent, the use is denied, and these notices count toward the limits of 6 per client and 20 in all, in any 60 seconds. A weekly summary lists every standing grant with its last use and its end date. From the phone: list, pause, revoke.
  9. Audit log. Make, use, rotate, the alarm on an old token or a second use, pause, revoke, end.
  10. One grant core. feat: scheduled grants: approve now, use later, with an end time and a number of uses #108 (one run planned ahead), this issue, and the one-use pre-approval of a login (feat: website logins from a shared Bitwarden collection, with TOTP codes made on the broker #103 step 9) are the same thing with other limits: a scope, a start, an end and a number of uses. Build one core, with one store, one set of audit events and one revoke path. The scope is a secret, an SSH key (key, host key, remote user) or a login (item, origin).

The honest limit

This finds a theft. It does not stop it. Anything on the client that can read the job's token can use it inside a window, before the job. Then the real job is denied, and the owner gets an alarm and a paused grant. But the secret is already out.

The same user can also write the job's token file. An attacker can use the token and put the new token into the file, so the old-token check sees nothing. The "one use per window" rule still catches it when the job runs in that window. An attacker who uses the token and also stops the job is seen only by the missed-window alarm (step 7). To stop a theft, the job must run where the agents cannot read its token (decision 3 A, or #109 with a runner the agents cannot reach).

A grant for a website login (#103) gives the whole account to that job, the TOTP code too. Allow it only for items marked for it.

Open questions

  1. Schedule format: cron, or a list of daily or weekly windows. Proposed: daily and weekly windows only, in broker local time, or UTC.
  2. One flag or two. feat: scheduled grants: approve now, use later, with an end time and a number of uses #108 uses preapprove = true, this issue standing = true. With one grant core, should a secret have one flag for all grant kinds?
  3. Where the token lives on the client, and that only the job reads it. A file with mode 0600 is still readable by every process of the same user.
  4. A token lost on the client (disk, restore from a backup with an old token, a lost answer, a retry): the old token is "reused" and pauses the grant. Proposed: that is right; renewal is one tap.
  5. Broker restart: standing grants must survive a restart, unlike the grants of a window tap. Saved state on the broker, checked against the audit log. This changes section 8 ("grants live only in memory"), and feat: scheduled grants: approve now, use later, with an end time and a number of uses #108 must match.
  6. A Deny. Section 8 says a Deny ends the client's grants. Does a Deny of another request end or pause a standing grant? Proposed: no; only Revoke and Pause do, and the message says so.

Done when

Related: #108 (one run planned ahead), #109 (job identity), #103 (logins, step 9 one-use pre-approval), #104 (agent-browser plugin).

Activity

  1. added this to the later milestone on Oct 27, 2026
  2. groupsky commented on Oct 27, 2026

    @groupsky
    ContributorAuthor

    Grants that survive a broker restart: #111 (for later). It answers the restart open question here.

  3. groupsky commented on Oct 29, 2026

    @groupsky
    ContributorAuthor

    Design plan for #110 (plan only, no code yet)

    I read this issue, #108 and its closing comment, #111, DESIGN.md section 8 ("Scheduled grants") and section 12, and the grant code in internal/approval. Below: what is there now, the plan, my answer to each open question, and the questions that need the owner.

    What the #108 core gives us, and what it lacks

    There is one grant struct (internal/approval/grants.go) and one end path (endWhere) for all end causes. The Revoke button and the message edits work through a queue, and they do not depend on the kind of grant. That is a good base.

    What standing grants need that is not there:

    • No kind field. "Scheduled" means id != "". audit.GrantEnded accepts only kind preapprove.
    • One time span only. live() checks one [start, end). The one timer (watchGrants / nextEnd) wakes only at an end. Nothing wakes at the start or the end of a window.
    • Uses are one total. There is no count per window.
    • A use checks no secret. It matches only client, secret names, host key and user. There is no token.
    • Limits. [preapprove] max_open has a ceiling of 24 hours. A standing grant lives for weeks.
    • End notice. Cause ended with uses left always sends the "not used" notice. That is wrong for a standing grant.
    • Telegram's checkPrompt knows only the fields of kind preapprove.

    The plan

    1. Grant core: add a kind. Give the grant a kind field: window, scheduled or standing. Replace id != "" with it. Add an optional list of windows to the grant. live(now) becomes: before the end, and, if there are windows, inside one of them. Keep the use count per window: the start time of the current window, and the uses in it. The timer also wakes at each window end (needed for the missed-window alarm). Everything still ends through endWhere, with new causes (below).

    2. Request: the same command, a new flag. nodkey preapprove gets --daily 03:00-05:00 and --weekly mon,thu 03:00-05:00 (at most one of them, at most 7 windows). With one of these flags, --until is a must, --uses means "uses per window" (default 1), and --from is optional (default: now). Same endpoint, POST /v1/preapprove, with a new optional windows field and a token_file given only to the client. The audit log writes kind standing. One command keeps the docs and the client small; the audit kind keeps the two apart.

    3. Rule flag and limits. A new rule flag standing = true, off by default, only for always and window rules (as preapprove). New config section [standing], with safe defaults. A config without it allows no standing grant:

    key default ceiling
    max_days (the end date) 90 366
    max_window (one window) 8 h 8 h
    max_uses (per window) 1 10
    max_grants (per client) 3 20
    warn_days (warning before the end) 7 -

    4. Token. After the tap, the broker makes a token: nkg_ + 32 random bytes (base62) + a CRC32 checksum, as GitHub does. The broker keeps only its SHA-256 hash. The token goes back only in the answer to nodkey preapprove. The client writes it to the token file and never prints it. A token works only with the mTLS key of its client.

    5. Use and rotation. nodkey run --grant-token FILE (and later nodkey ssh-agent, see question C) reads the token and sends it in a header. The broker:

    1. Checks the hash and the client, that the grant covers the whole request, that now is inside a window, and the uses left in that window.
    2. Makes a new token and writes grant_used (via standing, with the hash of the new token) to disk.
    3. Sends the silent notice (it counts toward the limits of 6 per client and 20 in all, as now).
    4. Answers with the secrets and the new token.

    The client writes the new token to a temp file, syncs it to disk and renames it over the old file. Only then does it use the secrets. If the write fails, the client does not run the command.

    6. Alarms. An alarm is a Telegram message with sound, and it is in the audit log (grant_alarm with a cause):

    • Old token (a hash in the list of old hashes; the broker keeps one 32-byte hash for each use until the grant ends): the request is denied, the grant ends (see question B), alarm.
    • Second use in one window (over --uses): denied, the grant ends, alarm.
    • Valid token outside the windows: denied, alarm. The grant stays.
    • Unknown token, or a token of another client: denied, an alarm with no grant (it may be a guess or a copied file). It is rate limited.
    • Missed window (a window ended with no use): alarm. The grant stays.

    A request without a token goes the normal way (ask on the phone), as today. Alarms count toward the limit of 20 notices in all, and each grant sends at most one alarm per window.

    7. Phone. The grant message gets a Revoke button, the same as in #108. Before the end (warn_days), a warning comes with a Renew button (question D). Each week, a summary lists every standing grant: client, secrets, windows, last use, end date. It uses one more config key, summary = "mon 09:00", in broker local time. A /grants bot command from the approver shows the same list now.

    8. Audit. Kind standing. decision carries not_before, not_after, the windows and the uses per window. grant_used gets the new token's hash. New grant_alarm. New end causes: alarm and renewed. These fields are also what #111 needs to rebuild a grant after a restart.

    9. Docs. DESIGN.md section 8 gets "The grant core", with three kinds: window, scheduled and standing. #103 step 9 is written down as a later fourth kind. Section 12 points to standing grants as the better form of decision 3 B. Sections 9, 10, 14 and 15 get the new events, header, config and flags. THREAT-MODEL.md gets T36: a standing grant used by something else on the client, the token file, and the honest limit. docs/setup.md gets a timer example with its own system user, and docs/manual-telegram-test.md gets a new check.

    10. Two pull requests, as for #108:

    • Part 1: core kind, windows, config, request, token, rotation, the use path and the use alarms, the audit events, DESIGN.md and THREAT-MODEL.md.
    • Part 2: missed-window alarm, warning and Renew, weekly summary, /grants, docs.

    My answers to the open questions

    1. Schedule format. Daily and weekly windows only, no cron, in broker local time, with the same summer-time rules as hours in section 12, so the two do not differ. A window that opens twice on the day the clocks go back still has one count. A window that falls into the skipped hour does not open; the missed-window alarm will show this. Run the broker in UTC to avoid it, as section 12 says.
    2. One flag or two. Two flags: preapprove = true and standing = true. A standing grant gives a much wider right (weeks, no tap for each run). One flag for both would quietly widen every config that has preapprove = true today.
    3. Where the token lives. A file given by --grant-token FILE. The client refuses a file that other users can read or write, or that the user does not own. The docs say to run the job as its own system user (systemd User= and StateDirectory=), so an agent that runs as another user cannot read it. The honest limit stays: the same user, or root, can read it. A systemd credential cannot be used, because the token changes on each use.
    4. A lost token. As proposed: an old token is an alarm, and the grant ends. A retry after a lost answer looks like a theft, and the broker cannot tell them apart. The client lowers the risk: it saves the new token before it does anything else, and it never retries a request with a token by itself.
    5. Broker restart. feat: standing and scheduled grants survive a broker restart, rebuilt from the audit log #111 decides this. Plan for feat: standing grants: one tap for a repeating job, built like an API key (end date, rotating token, alarms) #110: standing grants are dropped at a stop, like scheduled grants. They are listed in the stop notice. The audit events already carry what feat: standing and scheduled grants survive a broker restart, rebuilt from the audit log #111 needs. The box "Standing grants survive a broker restart" moves to feat: standing and scheduled grants survive a broker restart, rebuilt from the audit log #111. See question A.
    6. A Deny. A Deny of another request for the same client and secret does not end a standing grant. A standing grant is long, and an "Approve once" Deny at noon should not break the night job. The Deny message says "standing grants stay; use Revoke to end them". Only Revoke, an alarm, and the end date end it.

    Questions for the owner

  4. groupsky commented on Oct 29, 2026

    @groupsky
    ContributorAuthor

    Owner answers (2026-10-29) to the questions in the plan comment above:

    The answers to open questions 1–6 in the plan stand as written.

  5. 12 remaining items

  6. groupsky commented on Oct 29, 2026

    @groupsky
    ContributorAuthor

    Done. Part 1 is #115, part 2 is #116, both squash merged. Evidence for each "Done when" box:

    Docs: docs/setup.md section 11 "A job that runs every night: standing grants"; docs/manual-telegram-test.md check 15.

    Follow-up: standing grants for SSH keys is #128 (milestone later, owner answer C).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions