fix(api-token): keep a token's scope when -getApiToken regenerates it, add -tokenScope (#6700)

* fix(api-token): keep a token's scope when the CLI regenerates it

RecreateByName deleted the named row and created a new one without a Scope,
so the insert took the column default of admin. Since -tokenName lets the CLI
regenerate any token, rotating a monitor or node-sync token silently turned it
into a full-access one.

The replacement now takes the scope of the row it replaces, and a new name
still gets admin as before. A stored scope this build does not know, as after
a downgrade, fails the rotation and leaves the row alone instead of guessing.

Assisted-by: Claude Code:claude-opus-5-5 (mostly)

* feat(cli): let -getApiToken choose the scope of the token it issues

-tokenScope sets the scope on both branches of -getApiToken: the token minted
on a fresh panel and the one regenerated on a populated panel. Without the flag
a regenerated token keeps its scope and a new one gets admin, so every existing
invocation, install.sh included, behaves as before.

An unknown scope is refused before anything is deleted, so a typo cannot
revoke the token it meant to rotate.

Assisted-by: Claude Code:claude-opus-5-5 (mostly)

* fix(api-token): keep a token's expiry when the CLI regenerates it

RecreateByName built the replacement row with ExpiresAt 0, so running
`x-ui setting -getApiToken -tokenName <name>` on a token issued through
the API with a deadline handed back one that never expires, and said
nothing about it - the same silent widening this branch fixed for scope.

The replacement now carries the replaced row's ExpiresAt. A token whose
deadline has already passed is refused instead of rotated, since keeping
the deadline would mint a dead token and dropping it would revive an
expired credential without limit; the expired row is left untouched.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
ilyusha
2026-10-02 20:05:27 +03:00
committed by GitHub
parent 721de5adde
commit 05a083eaef
4 changed files with 229 additions and 19 deletions
+23 -7
View File
@@ -57,12 +57,12 @@ func TestGetApiTokenRotatesOnlyTheNamedToken(t *testing.T) {
newTokenCLIEnv(t)
svc := panel.ApiTokenService{}
weekly, err := svc.RecreateByName("weekly-report")
weekly, err := svc.RecreateByName("weekly-report", "")
if err != nil {
t.Fatalf("seed weekly-report: %v", err)
}
GetApiToken(true, "ci-bot")
GetApiToken(true, "ci-bot", "")
names := tokenNames(t)
if !hasName(names, "ci-bot") {
@@ -78,7 +78,7 @@ func TestGetApiTokenRotatesOnlyTheNamedToken(t *testing.T) {
func TestGetApiTokenUsesGivenNameOnEmptyDatabase(t *testing.T) {
newTokenCLIEnv(t)
GetApiToken(true, "ci-bot")
GetApiToken(true, "ci-bot", "")
names := tokenNames(t)
if !hasName(names, "ci-bot") {
@@ -89,15 +89,31 @@ func TestGetApiTokenUsesGivenNameOnEmptyDatabase(t *testing.T) {
}
}
// -tokenScope has to reach both branches, or a fresh panel would mint an admin
// token for a caller that asked for monitor.
func TestGetApiTokenAppliesGivenScope(t *testing.T) {
newTokenCLIEnv(t)
GetApiToken(true, "ci-bot", model.ApiScopeMonitor)
if got := tokenRow(t, "ci-bot").Scope; got != model.ApiScopeMonitor {
t.Fatalf("minted scope = %q, want %q", got, model.ApiScopeMonitor)
}
GetApiToken(true, "ci-bot", model.ApiScopeNodeSync)
if got := tokenRow(t, "ci-bot").Scope; got != model.ApiScopeNodeSync {
t.Fatalf("regenerated scope = %q, want %q", got, model.ApiScopeNodeSync)
}
}
// install.sh records the token it gets on a fresh panel. A later bare
// -getApiToken must rotate the fallback slot and leave that record valid.
func TestGetApiTokenPreservesInstallTokenWhenRotating(t *testing.T) {
newTokenCLIEnv(t)
GetApiToken(true, "")
GetApiToken(true, "", "")
installed := tokenRow(t, installTokenName)
GetApiToken(true, "")
GetApiToken(true, "", "")
names := tokenNames(t)
if !hasName(names, cliFallbackTokenName) {
@@ -134,10 +150,10 @@ func TestGetApiTokenWarnsOnIgnoredPositionalArgs(t *testing.T) {
func TestGetApiTokenTrimsName(t *testing.T) {
newTokenCLIEnv(t)
if _, err := (&panel.ApiTokenService{}).RecreateByName("seed"); err != nil {
if _, err := (&panel.ApiTokenService{}).RecreateByName("seed", ""); err != nil {
t.Fatalf("seed: %v", err)
}
GetApiToken(true, " ")
GetApiToken(true, " ", "")
names := tokenNames(t)
if !hasName(names, cliFallbackTokenName) {