Skip to content

feat: API key edit, protected mode, system terminal and files#128

Merged
nfebe merged 3 commits into
mainfrom
feat/2026-05
May 25, 2026
Merged

feat: API key edit, protected mode, system terminal and files#128
nfebe merged 3 commits into
mainfrom
feat/2026-05

Conversation

@nfebe
Copy link
Copy Markdown
Contributor

@nfebe nfebe commented May 25, 2026

Summary

  • API keys can now be edited and carry per-deployment access levels (read/write/admin), capped by the owning user's level. The owner-resolution path no longer returns a 401 when an admin API key creates a new key (which was logging users out in the UI).
  • New protected-mode controls for deployments and for the global system terminal: configurable blocked actions, blocked command rules, and an enable/disable switch.
  • New system-wide endpoints for the file manager and terminal, gated by new permissions, with chmod and file creation support and a cheap files-info variant for fast initial render.

Closes #117
Closes #122
Closes #123

nfebe added 3 commits May 25, 2026 10:20
Creating an API key while authenticated via a token that does not carry
a user identity (such as a legacy admin JWT) failed with a 401 that
the frontend interpreted as a session expiry and logged the user out.

The handler now falls back to the first admin user when the actor is
authenticated as admin but has no attached user record, and refuses
with 403 (not 401) for non-admin paths without a user. This stops the
spurious sign-out and keeps the legitimate auth failure paths intact.

The API key response also stops shipping the Go zero timestamp for
keys that never expire or have never been used. Those fields are now
emitted as null, so clients can render them correctly instead of
treating a key created today as already expired in the year 1.
API keys are now editable after creation through a new PUT endpoint
that mirrors the same authorisation rules as create. Name, description,
role, permissions, deployment scope, and expiry are all modifiable
without revoking and recreating the key. A non-admin actor cannot
elevate a key to admin or grant permissions it does not itself hold.

Each entry in a key's deployment scope now carries an explicit access
level (read, write, or admin) rather than being a plain membership
flag. The auth middleware treats the entry as a cap on the user's
level, so a key scoped to "deployments:write" cannot perform admin
actions even if the owning user has admin access to that deployment.

Existing keys keep working. The wire format accepts both the new
object shape and the legacy array form: array entries are interpreted
as admin level for backward compatibility, and serialized rows in the
database with the old shape are upgraded transparently on read.

Closes #123
Protected mode lets an admin lock a deployment so destructive actions
(env edits, compose changes, redeploys, file deletes, quick actions)
are refused with a 423 Locked response while the mode is on. The
admin can further deny shell sessions outright and ship a list of
command patterns that are blocked inside the terminal and quick
actions. Each refusal carries the rule that triggered it.

A host-level terminal endpoint backs the new System Terminal page in
the UI. Commands flow over a WebSocket, are authorised by a new
system:write permission, and pass through the same command-pattern
filter as the deployment terminal so global rules apply uniformly.

A new system:files permission and a parallel set of endpoints under
/api/system/files expose a filesystem manager rooted at a configurable
path. Operators can list, read, write, mkdir, touch, delete, and
chmod files outside any deployment, with traversal protection and a
setuid/setgid/sticky guard on the mode. Deployment file management
gains the same chmod and file-create endpoints so both contexts share
the same model.

Closes #117
Closes #122
@sourceant
Copy link
Copy Markdown

sourceant Bot commented May 25, 2026

Code Review Summary

This PR introduces significant security and administrative features, including editable API keys with granular deployment access, a 'Protected Mode' for deployments to prevent accidental destructive actions, and system-wide file and terminal management. The core architectural change is moving from simple list-based deployment access to a level-based map.

🚀 Key Improvements

  • Added granular access levels (read/write/admin) per deployment for API keys.
  • Implemented a 'Protected Mode' framework to block specific actions (delete, update env) or specific commands via regex/string matching.
  • Introduced system-level file management and terminal access gated by admin permissions.

💡 Minor Suggestions

  • Use more efficient database queries for finding admin users instead of filtering the whole list in memory.
  • Improve terminal command reconstruction to handle terminal control characters better.

🚨 Critical Issues

  • Potential for command injection in system terminal if shell meta-characters bypass the blocklist.
  • Performance risk with recursive disk usage calculations on the system root.

Copy link
Copy Markdown

@sourceant sourceant Bot left a comment

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment on lines +133 to +167
}

command := parts[0]
args := parts[1:]
if command == "ll" {
command = "ls"
args = append([]string{"-la"}, args...)
raw = strings.Join(append([]string{command}, args...), " ")
}
if command == "la" {
command = "ls"
args = append([]string{"-A"}, args...)
raw = strings.Join(append([]string{command}, args...), " ")
}

if command == "cd" {
target := session.cwd
if len(args) > 0 {
target = resolveSystemTerminalPath(session.cwd, args[0])
}
info, err := os.Stat(target)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("not a directory: %s", target)
}
session.cwd = target
return "", nil
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "sh", "-lc", raw)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The system terminal executes commands using sh -lc. If the input raw is not properly sanitized, an attacker might be able to inject commands despite the protectedCommandBlocked check (e.g., using command substitution or redirection).

Suggested change
}
command := parts[0]
args := parts[1:]
if command == "ll" {
command = "ls"
args = append([]string{"-la"}, args...)
raw = strings.Join(append([]string{command}, args...), " ")
}
if command == "la" {
command = "ls"
args = append([]string{"-A"}, args...)
raw = strings.Join(append([]string{command}, args...), " ")
}
if command == "cd" {
target := session.cwd
if len(args) > 0 {
target = resolveSystemTerminalPath(session.cwd, args[0])
}
info, err := os.Stat(target)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("not a directory: %s", target)
}
session.cwd = target
return "", nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-lc", raw)
// Consider using a more restricted shell or ensuring 'raw' does not contain malicious shell meta-characters
cmd := exec.CommandContext(ctx, "sh", "-lc", "--", raw)

@nfebe nfebe merged commit 01d91d4 into main May 25, 2026
5 checks passed
@nfebe nfebe deleted the feat/2026-05 branch May 25, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant