Compare commits
11 Commits
755cd2d3c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4fb5a6f4e | |||
| 11efdaa331 | |||
| 98ad60c0b8 | |||
| baa81c4923 | |||
| c0b01debba | |||
| f8c99f3e21 | |||
| d47d0d361d | |||
| 7d0de4f57a | |||
| f56f233704 | |||
| 9a15406f76 | |||
| 23db3d8fe0 |
@@ -4,12 +4,17 @@
|
||||
|
||||
## main — 2026-09-29
|
||||
|
||||
- 登录增加 MySQL 持久失败计数、临时锁定、账号/IP 限流、可信代理解析与审计查询,阈值及时间可配置;迁移 000035,无外键。
|
||||
- 补齐根页面和实际滚动区的边界回弹限制,修复侧栏与内容随触控板弹动。
|
||||
|
||||
- 修复长页面滚动体验:固定导航与页头、内容独立滚动,应用详情后台刷新避免重建相同快照及打断交互。
|
||||
|
||||
- 筛选范围改为可搜索的浅色菜单;四个资源列表默认每页 10 条,登录页大屏内容居中,统一筛选区与表格间距。
|
||||
|
||||
- 管理端统一浅色工作台、分组导航、紧凑总览、中文状态和移动端布局。
|
||||
- 任务/流程增加服务端状态、精确业务键和时间筛选;任务、计划、流程、订阅增加游标分页、页大小与 ID 定位。刷新保留查询,迟到响应不覆盖新结果。
|
||||
- 应用搜索、接入配置集中展示、低频操作折叠;详情支持复制 ID、键盘焦点与返回入口,Token 查看/复制及原有审计规则保持。
|
||||
- 本批只更新控制台及说明,不修改 API、SDK 或数据库结构。SDK 继续使用 v0.1.2,Outbox 继续使用 v0.1.1。[验收记录](docs/reports/console-refresh.md)。
|
||||
- 此前浅色工作台改版只更新控制台;本次登录保护增加管理接口与迁移 000035,业务 SDK 协议不变。SDK 继续使用 v0.1.2,Outbox 继续使用 v0.1.1。[验收记录](docs/reports/console-refresh.md)。
|
||||
|
||||
## v0.1.2 — 2026-09-27
|
||||
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,17 +1,19 @@
|
||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS build
|
||||
FROM registry.cn-shenzhen.aliyuncs.com/yuntuo_tools/golang:1.27.1-alpine AS build
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
ARG REVISION=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
|
||||
WORKDIR /src
|
||||
ENV GOWORK=off
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOPROXY=https://goproxy.cn,direct \
|
||||
GOWORK=off
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。
|
||||
- **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。
|
||||
- **Console**:账号密码登录的管理工作台,按应用与环境查看运行快照;支持任务/流程筛选、资源分页、ID 定位、审计与接入配置复制。[控制台使用说明](docs/console.md)。
|
||||
- **Console**:账号密码登录的管理工作台,按应用与环境查看运行快照;支持任务/流程筛选、资源分页、ID 定位、审计与接入配置复制;具备连续错误临时锁定、账号/IP 限流及登录审计。[控制台使用说明](docs/console.md) · [登录保护配置](docs/console-login-security.md)。
|
||||
|
||||
主分支:`main`。当前版本:**`v0.1.2`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,17 +17,20 @@ import (
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
func runCredential(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply")
|
||||
return errors.New("usage: orbit-admin credential status|permissions|rotate|expiry|finalize|revoke|apply")
|
||||
}
|
||||
switch args[0] {
|
||||
case "status":
|
||||
return runCredentialStatus(args[1:])
|
||||
case "rotate":
|
||||
return runCredentialChange(args[1:], "ROTATE")
|
||||
case "permissions":
|
||||
return runCredentialChange(args[1:], "SET_PERMISSIONS")
|
||||
case "expiry":
|
||||
return runCredentialChange(args[1:], "SET_EXPIRY")
|
||||
case "finalize":
|
||||
@@ -36,7 +40,7 @@ func runCredential(args []string) error {
|
||||
case "apply":
|
||||
return runCredentialApply(args[1:])
|
||||
default:
|
||||
return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply")
|
||||
return errors.New("usage: orbit-admin credential status|permissions|rotate|expiry|finalize|revoke|apply")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +79,11 @@ func runCredentialChange(args []string, action string) error {
|
||||
grace = 7 * 24 * time.Hour
|
||||
}
|
||||
var expected uint64
|
||||
var actionList string
|
||||
prepareOnly := flags.Bool("prepare-only", false, "save the fixed private operation without applying it; later use credential apply")
|
||||
if action == "SET_PERMISSIONS" {
|
||||
flags.StringVar(&actionList, "actions", "", "complete comma-separated permission actions; existing queue scope is preserved")
|
||||
}
|
||||
flags.StringVar(&namespace, "namespace-id", "", "credential namespace id")
|
||||
flags.StringVar(&credential, "credential-id", "", "credential id")
|
||||
flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply")
|
||||
@@ -92,6 +101,22 @@ func runCredentialChange(args []string, action string) error {
|
||||
if err != nil || expected == 0 {
|
||||
return errors.New("credential change requires valid ids and a nonzero --expected-revision")
|
||||
}
|
||||
var actions []string
|
||||
if action == "SET_PERMISSIONS" {
|
||||
invalidLifetime := false
|
||||
flags.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "valid-for" || f.Name == "grace" {
|
||||
invalidLifetime = true
|
||||
}
|
||||
})
|
||||
if invalidLifetime {
|
||||
return errors.New("permission changes preserve validity and overlap; omit --valid-for and --grace")
|
||||
}
|
||||
actions, err = parseCredentialActions(actionList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
duration := time.Duration(0)
|
||||
if action == "ROTATE" || action == "SET_EXPIRY" {
|
||||
duration, err = time.ParseDuration(validFor)
|
||||
@@ -105,7 +130,7 @@ func runCredentialChange(args []string, action string) error {
|
||||
}
|
||||
defer closeDB()
|
||||
op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{
|
||||
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason,
|
||||
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason, Actions: actions,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.New("credential operation could not be prepared")
|
||||
@@ -113,6 +138,12 @@ func runCredentialChange(args []string, action string) error {
|
||||
if err := writePrivateOperation(output, op); err != nil {
|
||||
return err
|
||||
}
|
||||
if *prepareOnly {
|
||||
return json.NewEncoder(os.Stdout).Encode(struct {
|
||||
OperationID core.ID `json:"operation_id"`
|
||||
Prepared bool `json:"prepared"`
|
||||
}{op.OperationID, true})
|
||||
}
|
||||
vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -124,6 +155,24 @@ func runCredentialChange(args []string, action string) error {
|
||||
return json.NewEncoder(os.Stdout).Encode(receipt)
|
||||
}
|
||||
|
||||
func parseCredentialActions(value string) ([]string, error) {
|
||||
actions := strings.Split(value, ",")
|
||||
seen := make(map[string]bool, len(actions))
|
||||
for i, action := range actions {
|
||||
action = strings.TrimSpace(action)
|
||||
if action == "" || seen[action] {
|
||||
return nil, errors.New("--actions requires a nonempty, unique, complete action list")
|
||||
}
|
||||
seen[action] = true
|
||||
actions[i] = action
|
||||
}
|
||||
if err := (auth.Permissions{Actions: actions, Queues: []string{"*"}}).Validate(); err != nil {
|
||||
return nil, errors.New("--actions contains an unsupported action or exceeds the action limit")
|
||||
}
|
||||
sort.Strings(actions)
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func runCredentialApply(args []string) error {
|
||||
flags := flag.NewFlagSet("credential apply", flag.ContinueOnError)
|
||||
file := ""
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -84,3 +85,42 @@ func TestPrivateOperationFileRejectsUnknownFieldsAndSymlinks(t *testing.T) {
|
||||
t.Fatal("symlink operation file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionActionList(t *testing.T) {
|
||||
actions, err := parseCredentialActions("tasks:read, schedules:manage,schedules:read")
|
||||
if err != nil || !reflect.DeepEqual(actions, []string{"schedules:manage", "schedules:read", "tasks:read"}) {
|
||||
t.Fatalf("invalid canonical action list: %v %v", actions, err)
|
||||
}
|
||||
for _, value := range []string{"", " ", "*", "tasks:*", "tasks:read,tasks:read", "tasks:read, tasks:read", "tasks:read,", "schedules:admin"} {
|
||||
if _, err := parseCredentialActions(value); err == nil {
|
||||
t.Fatalf("accepted invalid action list %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionCommandRejectsLifetimeBeforeDatabaseAccess(t *testing.T) {
|
||||
op := testOperation(t)
|
||||
for _, flag := range []string{"--valid-for=0", "--grace=0"} {
|
||||
err := runCredential([]string{"permissions", "--namespace-id=" + string(op.NamespaceID), "--credential-id=" + string(op.CredentialID), "--expected-revision=1", "--reason=test", "--output=" + filepath.Join(t.TempDir(), "op.json"), "--actions=tasks:read", flag})
|
||||
if err == nil || err.Error() != "permission changes preserve validity and overlap; omit --valid-for and --grace" {
|
||||
t.Fatalf("lifetime field not rejected: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivatePermissionOperationReplaysWithoutToken(t *testing.T) {
|
||||
op := testOperation(t)
|
||||
op.Version = 3
|
||||
op.Action = "SET_PERMISSIONS"
|
||||
op.Token = ""
|
||||
op.ExpiresAt = nil
|
||||
op.Actions = []string{"schedules:manage", "schedules:read", "tasks:read"}
|
||||
path := filepath.Join(t.TempDir(), "permissions.json")
|
||||
if err := writePrivateOperation(path, op); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
read, err := readPrivateOperation(path)
|
||||
if err != nil || !reflect.DeepEqual(read, op) {
|
||||
t.Fatalf("permission operation identity changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func main() {
|
||||
}
|
||||
func run() error {
|
||||
if len(os.Args) < 2 {
|
||||
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|rotate|revoke|apply")
|
||||
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|permissions|rotate|expiry|finalize|revoke|apply")
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "release":
|
||||
@@ -46,7 +46,7 @@ func run() error {
|
||||
case "credential":
|
||||
return runCredential(os.Args[2:])
|
||||
}
|
||||
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|rotate|revoke|apply")
|
||||
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|permissions|rotate|expiry|finalize|revoke|apply")
|
||||
}
|
||||
|
||||
// releaseManifest 是发布物自身的静态身份清单,不读取数据库、不包含 DSN、凭据或业务数据。
|
||||
|
||||
@@ -93,7 +93,29 @@ func run() error {
|
||||
txStats := mysql.ReadTransactionStats()
|
||||
metrics.RecordTransactionStats(observability.TransactionStats{Retries: txStats.Retries, Exhausted: txStats.Exhausted})
|
||||
}
|
||||
consoleAuth := mysql.NewConsoleAuthStore(db)
|
||||
consoleAuth := mysql.NewConsoleAuthStore(db, cfg.ConsoleLogin)
|
||||
// 登录安全记录独立保留,每分钟有界清理,关闭时等待清理器退出。
|
||||
loginCleanupCtx, stopLoginCleanup := context.WithCancel(root)
|
||||
loginCleanupDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(loginCleanupDone)
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-loginCleanupCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
call, cancel := context.WithTimeout(loginCleanupCtx, 5*time.Second)
|
||||
err := consoleAuth.CleanupLoginSecurity(call)
|
||||
cancel()
|
||||
if err != nil && loginCleanupCtx.Err() == nil {
|
||||
slog.Error("console login security cleanup failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() { stopLoginCleanup(); <-loginCleanupDone }()
|
||||
retentionStore := mysql.NewRetentionPolicyStore(db, cfg.RetentionDefaults)
|
||||
server := httpserver.NewWithConsoleAuth(cfg.HTTPAddr, readiness.Check, consoleAuth, service.New(mysql.NewTaskStore(db)))
|
||||
httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db)))
|
||||
|
||||
@@ -25,6 +25,14 @@ x-orbit-environment: &orbit-environment
|
||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||
ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT: ${ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT:-5}
|
||||
ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW: ${ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW:-15m}
|
||||
ORBIT_CONSOLE_LOGIN_LOCK_DURATION: ${ORBIT_CONSOLE_LOGIN_LOCK_DURATION:-15m}
|
||||
ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT: ${ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT:-10}
|
||||
ORBIT_CONSOLE_LOGIN_IP_LIMIT: ${ORBIT_CONSOLE_LOGIN_IP_LIMIT:-30}
|
||||
ORBIT_CONSOLE_LOGIN_RATE_WINDOW: ${ORBIT_CONSOLE_LOGIN_RATE_WINDOW:-1m}
|
||||
ORBIT_CONSOLE_LOGIN_AUDIT_DAYS: ${ORBIT_CONSOLE_LOGIN_AUDIT_DAYS:-90}
|
||||
ORBIT_CONSOLE_TRUSTED_PROXIES: ${ORBIT_CONSOLE_TRUSTED_PROXIES:-}
|
||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||
|
||||
services:
|
||||
|
||||
@@ -93,6 +93,14 @@ services:
|
||||
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||
ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT: ${ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT:-5}
|
||||
ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW: ${ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW:-15m}
|
||||
ORBIT_CONSOLE_LOGIN_LOCK_DURATION: ${ORBIT_CONSOLE_LOGIN_LOCK_DURATION:-15m}
|
||||
ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT: ${ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT:-10}
|
||||
ORBIT_CONSOLE_LOGIN_IP_LIMIT: ${ORBIT_CONSOLE_LOGIN_IP_LIMIT:-30}
|
||||
ORBIT_CONSOLE_LOGIN_RATE_WINDOW: ${ORBIT_CONSOLE_LOGIN_RATE_WINDOW:-1m}
|
||||
ORBIT_CONSOLE_LOGIN_AUDIT_DAYS: ${ORBIT_CONSOLE_LOGIN_AUDIT_DAYS:-90}
|
||||
ORBIT_CONSOLE_TRUSTED_PROXIES: ${ORBIT_CONSOLE_TRUSTED_PROXIES:-}
|
||||
ORBIT_CREDENTIAL_KEY_FILE: ${ORBIT_CREDENTIAL_KEY_FILE:-}
|
||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||
|
||||
@@ -81,6 +81,7 @@ server {
|
||||
}
|
||||
|
||||
# 控制台登录额外限制来源 IP 的尝试速率;业务任务速率仍由 Orbit 的持久化配额和授权决定。
|
||||
# Orbit 另有数据库持久化账号/IP 保护;配置 ORBIT_CONSOLE_TRUSTED_PROXIES 为此代理的实际地址。
|
||||
location = /console/auth/login {
|
||||
limit_req zone=orbit_console_login burst=5 nodelay;
|
||||
proxy_pass http://orbit_backend;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Orbit 持续开发与回归计划
|
||||
|
||||
更新:2026-09-24。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。
|
||||
更新:2026-11-01。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。
|
||||
|
||||
2026-11-01 补齐旧凭据动作权限管理:控制台与管理 CLI 共用 SET_PERMISSIONS v3 固定操作、revision 校验、幂等回执和原子审计;保留队列范围、秘密、有效期及过渡时间。隔离 MySQL 已验证原 Token 即时授予/收回、真实锁等待竞争、审计失败回滚和 v1/v2 旧操作兼容;HTTP 与页面提交重试回归通过。无新增迁移,业务 SDK 仍不开放凭据管理,原生产验收边界不变。协议及命令见 [认证手册](api/authentication.md#原-token-调整动作权限) 与 [控制台接入](api/console-applications.md#调整已有凭据的动作权限),验证范围见 [权限管理回归](reports/credential-permissions-20261101.md)。
|
||||
|
||||
## 1. 最终交付目标
|
||||
|
||||
@@ -337,3 +339,24 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
||||
- [x] 四个资源列表默认每页 10 条,保留服务端游标与原条件。
|
||||
- [x] 登录页左侧内容居中、主工作区最大宽度与移动布局调整。
|
||||
- [x] 浏览器交互及分页回归、基础构建、本地 Docker 部署与文档同步;见[验收记录](reports/console-refresh.md)。
|
||||
|
||||
## 2026-09-29:滚动稳定性与登录保护核对
|
||||
|
||||
- [x] 控制台独立内容滚动,固定导航与页头,保留移动端导航和表格横向滚动。
|
||||
- [x] 应用详情相同快照不重建,后台刷新在滚动/编辑/展开详情时让位,检查请求返回时的交互状态。
|
||||
- [x] 核对当时登录实现的失败次数封控和登录限流缺口;后续已在下方专项补齐。
|
||||
- [x] 浏览器滚动、刷新和切页回归,基础检查及持久 Docker 部署;验收见[管理端改版记录](reports/console-refresh.md)。
|
||||
|
||||
## 2026-09-30:滚动边界补修与登录保护
|
||||
|
||||
- [x] 关闭根页面、侧栏及各内容滚动区的边界回弹;验证连续滚动与短屏导航。
|
||||
- [x] MySQL 持久化失败窗口、临时锁定及账号/IP 限流,配置校验、可信代理识别。
|
||||
- [x] 登录成功/失败/拒绝审计,单管理员查询、保留期及有界清理,登录页等待提示。
|
||||
- [x] 真实 MySQL 并发/跨实例/恢复/审计失败回归,基础检查、浏览器验证、Docker 部署与文档同步。
|
||||
|
||||
本地部署修订 `f56f233704700d43c6733b7f0fa14d67e1feddf7`,Schema 35,无外键。基础检查、真实 MySQL、双节点登录与控制台端到端通过;广泛集成在 Docker 暂时无响应处中断,原参数补跑剩余链路全部通过。证据与验证边界见[验收记录](reports/console-refresh.md),默认策略和部署配置见[登录保护](console-login-security.md)。
|
||||
|
||||
## 2026-09-30:浏览器页签图标
|
||||
|
||||
- [x] 复用现有 Orbit 品牌图形,增加 SVG 和 16/32/48 像素 ICO 图标,登录页与管理端共用。
|
||||
- [x] `make test vet build docs-check` 通过;本地部署修订 `d47d0d361d7edbcadf5728013cebcf1a2b908cc7`。未登录可请求两种图标,HTTP 200、媒体类型与文件内容正确;登录页与实际控制台均引用图标,readiness 为 ready。
|
||||
|
||||
@@ -56,7 +56,7 @@ go run ./cmd/orbit-admin credential status \
|
||||
--credential-id <credential_id>
|
||||
```
|
||||
|
||||
状态输出不包含令牌、秘密摘要或权限正文;`revision` 是后续变更的并发条件。
|
||||
状态输出包含精确动作与队列权限,不包含令牌或秘密摘要;`revision` 是后续变更的并发条件。
|
||||
|
||||
```sh
|
||||
go run ./cmd/orbit-admin credential rotate \
|
||||
@@ -106,7 +106,25 @@ go run ./cmd/orbit-admin credential finalize \
|
||||
|
||||
结束过渡立即停用旧 Token,当前 Token 不变。长期 Token 仍受主动撤销、应用/命名空间停用约束。旧代仍在有效过渡期时再次轮换返回 409,避免误伤未切换服务;先完成本次切换。修改当前有效期不延长旧代截止。若旧 Token 本身即将到期而需要更长切换窗口,应先调整当前有效期,再发起换证。
|
||||
|
||||
新固定操作文件使用 version=2;历史 version=1 仍按原来的固定到期和立即轮换语义重放,不自动延长旧秘密。
|
||||
轮换、有效期、结束过渡与撤销的新固定操作文件继续使用 version=2;历史 version=1 仍按原来的固定到期和立即轮换语义重放,不自动延长旧秘密。权限修改使用下面的 version=3,不改变旧操作内容或摘要。
|
||||
|
||||
### 原 Token 调整动作权限
|
||||
|
||||
`credential permissions` 修改已有凭据的完整动作集合,保留队列授权范围、Token、密文、有效期、旧代过渡期和资源身份。先用 status 读取当前权限与 revision,再明确保留或移除哪些动作。`--actions` 是全量替换,不能只填写希望新增的动作;空集合、未知动作、通配符和重复动作会被拒绝,准备时按字典序固定。
|
||||
|
||||
```sh
|
||||
go run ./cmd/orbit-admin credential permissions \
|
||||
--namespace-id <namespace_id> --credential-id <credential_id> \
|
||||
--expected-revision <revision> \
|
||||
--actions 'queues:read,schedules:manage,schedules:read,tasks:read,tasks:submit' \
|
||||
--reason 'enable scheduling for this credential' \
|
||||
--output .local/permissions.operation.json --prepare-only
|
||||
go run ./cmd/orbit-admin credential apply --file .local/permissions.operation.json
|
||||
```
|
||||
|
||||
上例动作仅作格式示例,实际必须根据原 status 核对完整集合。`--prepare-only` 只保存 0600 固定操作,不修改凭据;不带该参数则保存后立即应用,沿用其他 credential 命令的行为。权限修改不接受 `--valid-for` 或 `--grace`,固定操作不含 Token。当前版本校验、权限变更、前后权限审计和原键回执在一个事务内提交,审计失败整笔回滚;提交不明时用原文件 apply,不能换 operation_id。旧操作重放只返回原回执,不会恢复后来已收回的权限。
|
||||
|
||||
权限随下一次认证/授权检查生效,原 Token 无须重新分发;移除 Worker 或消费相关权限可能影响既有连接的后续授权。该管理能力仅由管理员控制台或受保护的管理 CLI 提供,业务 SDK 不获得凭据管理接口。
|
||||
|
||||
发生泄露或 Worker 必须立即停用时执行撤销:
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export ORBIT_CREDENTIAL_KEY_FILE=/run/orbit-secrets/credential-keys.json
|
||||
4. 已接入应用点击「查看接入信息」或「复制接入配置」,获取包含当前 Token 的完整配置;也可下载 orbit.env。
|
||||
5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。
|
||||
6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。
|
||||
7. 在凭据的「管理与权限」中选择「调整权限」,核对当前精确动作集合并填写原因。只修改动作,队列授权、Token、有效期和过渡期保持不变;高级恢复、审计和额度权限需要明确选择。
|
||||
|
||||
凭据卡片提供「查看接入信息」「复制接入配置」。配置包含 ORBIT_URL、ORBIT_APPLICATION_ID、ORBIT_NAMESPACE_ID、ORBIT_QUEUE_ID、ORBIT_TOKEN;可直接粘贴为环境配置。先选择业务服务实际可访问的地址和队列,再复制;应用 ID 用于识别,真正认证身份来自 Token。过渡期始终返回当前新 Token,调整有效期或结束旧代过渡后仍可查看。历史凭据尚无密文时提供「导入当前 Token」,不能把仍可用的旧代 Token 导入为当前代次。
|
||||
|
||||
@@ -132,6 +133,26 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长
|
||||
|
||||
完整滚动更新及 SDK 客户端生命周期说明见[凭据手册](authentication.md)。更换配置不会自动刷新已创建的客户端,须在过渡截止前完成重建或逐台重启并排空在途任务。
|
||||
|
||||
### 调整已有凭据的动作权限
|
||||
|
||||
prepare 使用 `kind=SET_PERMISSIONS`,携带当前 namespace_id、credential_id、expected_revision、完整 actions 数组和 reason;valid_days 为 0,grace_days 省略或为 0。actions 必须非空,至多 32 个,拒绝重复、未知动作与通配符。其他操作类型不接受 actions,不能把权限修改混入换证或有效期调整。
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "SET_PERMISSIONS",
|
||||
"namespace_id": "33333333333333333333333333333333",
|
||||
"credential_id": "55555555555555555555555555555555",
|
||||
"expected_revision": "2",
|
||||
"valid_days": 0,
|
||||
"actions": ["queues:read", "schedules:manage", "schedules:read", "tasks:read", "tasks:submit"],
|
||||
"reason": "允许该凭据管理定时计划"
|
||||
}
|
||||
```
|
||||
|
||||
actions 为**全量替换**,示例不能代替读取原权限;页面默认勾选原有动作,不默认补齐能力组。prepare 返回 version=3 的固定操作,不生成或返回 Token、期限或过渡时间;原对象整体提交到已有 apply 路由。新动作要求 version=3,旧 CREATE、ROTATE、SET_EXPIRY、FINALIZE 保持原固定格式与重放语义。
|
||||
|
||||
服务端仅更新原凭据动作集合和 revision,保留原队列授权与所有秘密/有效期字段;权限前后快照、管理员身份、原因和回执原子提交。审计失败不改权限,旧版本返回冲突,提交不明重放原 operation_id。页面成功后刷新详情;旧回执不代表当前权限仍等于该次修改后的状态。权限将在后续认证/授权检查生效,移除正在使用的能力可能影响对应连接后续操作。
|
||||
|
||||
## 6. 超时、重复和审计
|
||||
|
||||
- 页面提交前把整个固定操作保留在当前内存;请求超时或结果未知时,按钮变成「重试确认」,重用同一份内容。不能再次 prepare 来代替核对。
|
||||
|
||||
65
docs/console-login-security.md
Normal file
65
docs/console-login-security.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 控制台登录保护
|
||||
|
||||
从 main 的数据库迁移 `000035` 开始启用。仅保护管理端账号密码登录,不改变业务 SDK Token、Worker 或结果订阅认证。保持单管理员,不引入角色管理系统。
|
||||
|
||||
## 默认行为
|
||||
|
||||
- 15 分钟失败窗口内连续失败 5 次,临时锁定 15 分钟。成功登录清空失败计数;失败窗口或锁定自然到期后重新计数。锁定期间尝试正确密码也不能登录,继续请求不会延长锁定。
|
||||
- 同一账号每分钟最多受理 10 次,同一来源 IP 每分钟最多 30 次。固定窗口从首次尝试开始;成功登录不清空限流计数。任意一项触发就拒绝。
|
||||
- 账号不存在、停用、密码错误或绑定凭据失效,正常拒绝均为 401。未知账号也参与相同限流/锁定,使用相同成本的 bcrypt 校验。既有账号按规范用户 ID 计数,避免等价 Unicode 登录名绕过限制。
|
||||
- 触发锁定或限流返回 429,响应包含 `Retry-After` 秒数。登录页显示原因和倒计时;刷新页面不能绕过服务端限制。已登录会话不受一次新登录锁定影响,不中断业务服务。
|
||||
- 数据库或审计提交失败返回 503,不签发 Cookie。跨站登录请求为 403;非法/过大的 JSON 为 400。
|
||||
|
||||
所有保护状态存放 MySQL,多节点共用,重启不归零。昂贵的密码校验在事务外执行:先用短事务占用请求预算,校验结束后再次锁定账号保护行;失败计数、锁定、成功会话和最终审计在同一事务提交。并发结果不能越过已经生效的账号锁定。校验进程异常退出的审计保留 PENDING,超过 10 分钟由清理器标 ERROR;不将未知结果当作密码错误。
|
||||
|
||||
## 部署配置
|
||||
|
||||
所有 Orbit 节点必须使用一致配置;不允许用 0 关闭保护。修改部署配置并重启服务后生效,不重置已有计数或已记录的锁定截止。降低阈值后按新阈值检查。
|
||||
|
||||
| 环境变量 | 默认值 | 约束 / 含义 |
|
||||
| --- | --- | --- |
|
||||
| `ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT` | `5` | 1–100 次,触发临时锁定 |
|
||||
| `ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW` | `15m` | 1s–24h,连续失败窗口 |
|
||||
| `ORBIT_CONSOLE_LOGIN_LOCK_DURATION` | `15m` | 1s–24h,新触发锁定时长 |
|
||||
| `ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT` | `10` | 1–1000 次 / 账号 / 限流窗口 |
|
||||
| `ORBIT_CONSOLE_LOGIN_IP_LIMIT` | `30` | 1–10000 次 / IP / 限流窗口 |
|
||||
| `ORBIT_CONSOLE_LOGIN_RATE_WINDOW` | `1m` | 1s–24h,固定限流窗口 |
|
||||
| `ORBIT_CONSOLE_LOGIN_AUDIT_DAYS` | `90` | 1–3650 天,登录审计独立保留期 |
|
||||
| `ORBIT_CONSOLE_TRUSTED_PROXIES` | 空 | 明确信任的代理 CIDR,逗号分隔 |
|
||||
|
||||
普通 Compose 和 HA Compose 均透传这些变量。本地测试环境在 `.local/compose-test/compose.env` 修改所需配置,再执行 `python3 scripts/orbit-local.py up`。此文件含其他私密配置,不提交仓库;不能仅在启动脚本外 export,脚本会隔离外部项目环境变量。
|
||||
|
||||
默认只使用 TCP 对端 IP,忽略客户端自带的 X-Forwarded-For、X-Real-IP。部署域名反向代理时,将实际代理的地址或精确网段填入 `ORBIT_CONSOLE_TRUSTED_PROXIES`,例如同机代理 `127.0.0.1/32,::1/128`。只在直连对端可信时解析 X-Forwarded-For,从右向左剥离可信代理;遇到第一个非可信地址停止。格式非法或跳数过多时退回 TCP 对端。代理应覆盖或按可信链追加转发头,不能把所有互联网地址都配置为受信代理。未配置时所有经代理的请求共用代理 IP 配额,账号维度仍独立生效。
|
||||
|
||||
## 查看审计
|
||||
|
||||
管理端右上角“登录审计”,仅部署指定的 `ORBIT_CONSOLE_APPLICATION_ADMIN` 可查看,默认每页 10 条。显示时间、登录名、来源 IP、结果和可重试时间;记录用户名不代表该账号一定存在。SDK Bearer、未登录或普通控制台账号无权读取全局记录。
|
||||
|
||||
接口:`GET /console/auth/audit?before=<上页next>`,认证为有效 HttpOnly 管理员会话 Cookie。响应:
|
||||
|
||||
```json
|
||||
{"data":{"items":[{"id":"42","username":"operator","remote_ip":"192.0.2.1","user_agent":"browser","outcome":"LOCKED","created_at":"2026-09-29T12:00:00Z","finished_at":"2026-09-29T12:00:00Z","retry_at":"2026-09-29T12:15:00Z"}],"next":"42"}}
|
||||
```
|
||||
|
||||
`next` 仅在还有下一页时提供,是倒序分页的排他上界;无全库计数、不承诺跨页快照。结果类型:SUCCESS、INVALID、LOCKED、ACCOUNT_LIMIT、IP_LIMIT、PENDING、ERROR。审计从不保存密码、Cookie、Token 或这些秘密的摘要,浏览器列表不会自动拉取接入 Token。
|
||||
|
||||
登录拒绝示例:
|
||||
|
||||
```http
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 900
|
||||
Cache-Control: no-store
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
限流代码为 `LOGIN_RATE_LIMITED`。不要让客户端自动循环重试账号密码登录。
|
||||
|
||||
## 保留与升级
|
||||
|
||||
迁移只新增 `orbit_console_login_guards` 与 `orbit_console_login_audit`,无外键,不更改已有用户、密码、会话、凭据及业务记录。先备份,停止旧节点,再前向迁移和统一部署;旧版没有保护,不能和新版混跑。
|
||||
|
||||
每分钟清理一次,每种语句最多处理 500 行:48 小时未更新的保护状态、超过配置天数的登录审计,以及超时 PENDING 的状态归类。有效保护窗口最长 24 小时,48 小时非活动清理不会提前解除有效锁定。清理有索引、有截止时间,失败保留记录并写错误日志;这不会缩小数据库已分配磁盘,也不替代备份。业务“保留策略”管理的是命名空间历史,登录审计的全局保留时间由上述独立配置管理。
|
||||
|
||||
锁定自然到期后使用正确密码登录即可;不自动改密码、撤销业务 Token 或踢出已有会话。若持续遭受攻击,管理员应按审计中的来源限制访问并核对反向代理配置,不能通过关闭计数绕过保护。
|
||||
锁定自然到期后使用正确密码登录即可;不自动改密码、撤销业务 Token 或踢出已有会话。若持续遭受攻击,管理员应按审计中的来源限制访问并核对反向代理配置,不能通过关闭计数绕过保护。
|
||||
@@ -20,6 +20,7 @@ http://127.0.0.1:8080/console/
|
||||
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
|
||||
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
||||
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
|
||||
- 工作台固定导航和页头,内容区独立滚动;切换模块回到内容顶部。应用详情相同快照不重建,后台刷新会避让正在滚动、编辑或展开详情的操作。
|
||||
- 左侧八个模块是独立视图:总览、任务、计划、流程、订阅、保留策略、归档作业和应用接入。
|
||||
- “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。
|
||||
- 当前视图按 10 秒自动刷新,可在页顶暂停;输入、打开详情或操作弹窗时不自动重载。页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。
|
||||
@@ -74,6 +75,8 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
||||
|
||||
## 安全边界
|
||||
|
||||
登录已具备持久化失败计数、临时锁定、账号/IP 限流和单管理员登录审计。默认 15 分钟内连续失败 5 次锁定 15 分钟,账号/IP 每分钟分别最多尝试 10/30 次;阈值、窗口、审计保留期和可信代理均可配置。页面显示 429 原因及倒计时,服务重启不清空保护状态。配置与接口见[登录保护](console-login-security.md)。
|
||||
|
||||
控制台通过服务端授权访问数据库。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。普通账号不自动获得跨命名空间权限;部署指定的单管理员可显式选择应用范围,每个业务事务重新核验原会话及目标应用。各应用分别展示,SDK 权限不变。
|
||||
|
||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
||||
|
||||
@@ -54,3 +54,35 @@ SDK、数据库 Schema 34、原应用与凭据保持。部署未重新 bootstrap
|
||||
- 本轮基础 test/vet/build、控制台端到端和 console-check/docs-check 通过。部署复核另附下方。
|
||||
|
||||
第二轮已部署至持久 Compose 环境:`/version` 修订为 `df76dcb9c0b69b3c947d112de619737145c44015`,就绪检查 HTTP 200。实际 18080 入口应用菜单、10 条列表、退出和原管理员登录通过;原应用身份与数据库保留。本次不涉及 SDK 发版。
|
||||
|
||||
## 第三轮:滚动稳定性
|
||||
|
||||
原布局让整个 document 滚动,页头会被带离视口;应用详情每次轮询都重建 DOM。改为固定视口的工作台、独立内容滚动、固定页头和桌面应用目录;后台请求在返回时再次检查滚动/编辑状态,相同应用快照保留原有 DOM。切换视图重置内容滚动区域,菜单仍按当前控件坐标定位。
|
||||
|
||||
浏览器验证:1440×900 与 390×844 下,document 不滚动,页头和导航坐标固定;鼠标点击页头刷新前后内容 scrollTop 均为 293、scrollHeight 均为 1193;展开状态不因相同快照刷新被折叠;手机切页归零、详情打开/关闭和菜单定位通过,无横向页面溢出或 JS 错误。基础 test/vet/build、console-check/docs-check 通过。
|
||||
|
||||
第三轮当时的登录保护核对:`ConsoleAuthStore.Login` 与 HTTP 登录路由没有失败计数、临时锁定和登录限流;当时仅记录缺口。下方 2026-09-30 专项已补齐这些能力。
|
||||
|
||||
第三轮持久部署已核验:`/version` 修订 `23db3d8fe0de352be9e756f932b12559ea746ff3`,`/health/ready` 为 200 ready。实际 18080 应用页内容 scrollTop=293 时,document scrollY=0、导航和页头 top=0。服务原账号、SDK、Token 和数据库保持不变。
|
||||
|
||||
## 2026-09-30:边界回弹与登录保护
|
||||
|
||||
上一轮键盘翻页和静态位置检查没有覆盖触控板滚到边界的回弹。原 `overscroll-behavior: contain` 只阻止滚动传递,仍允许元素回弹([MDN 属性说明](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overscroll-behavior#values))。本轮在控制台根节点、侧栏、内容、应用列表及弹层的实际滚动容器设置 `none`,并固定根工作区;保留内容正常滚动和短屏菜单可达性。
|
||||
|
||||
浏览器预览验证:
|
||||
|
||||
- 1440×900,用滚动操作连续冲顶部、底部并反向:根 scrollY=0,侧栏 y=0、品牌 y=28、页头 y=0;内容 scrollTop 在 0–293 内移动。
|
||||
- 1100×600,菜单在 0–198 内滚动,内容在 0–593 内独立滚动,另一侧不被拖动,页头 y=0。
|
||||
- 390×844,顶部导航固定,内容滚动至 1112;根 scrollY=0、文档宽度 390,无根横向溢出。工具滚动不等同于所有设备的物理触控板惯性手势,以上为实际验证范围。
|
||||
|
||||
登录保护实现、配置和 API 见[控制台登录保护](../console-login-security.md)。已有真实 MySQL 回归覆盖失败/成功计数重置、两个存储实例与重建实例共用锁定、锁定不续期与自然到期、并发 6 次失败精确计数、账号/IP 配额、未知账号、失败窗口、审计提交失败回滚会话、管理员权限/分页、日志保留和零外键。独立 Docker 双服务实测还覆盖轮流失败、重启服务仍拒绝、已登录会话仍有效、伪造代理头无效、自然到期后登录成功。
|
||||
|
||||
端到端首轮旧管理查询曾超时;复跑该链路通过,未放宽断言。双节点测试首次重启后因 Docker 重新分配随机发布端口无法就绪,测试工具改为重读实际端口后通过,未修改锁定逻辑或删除状态。最终 `make console-e2e` 全链路通过,含任务执行、重试、定时计划、归档及退出登录。
|
||||
|
||||
本地持久 Compose 部署版本 `f56f233704700d43c6733b7f0fa14d67e1feddf7`,数据库 Schema 35、0 外键,readiness 为 ready。已有账号、密码、会话、凭据与业务记录未重新初始化。实际 18080 浏览器验证:独立的未知测试登录名在第 5 次错误后显示 15 分钟倒计时;真实管理员正常登录,回到原应用页,审计显示失败、锁定与成功。正式页面滚动至 293 时,根 y=0、侧栏 y=0、品牌 y=28、页头 y=0,无浏览器 JS 错误。
|
||||
|
||||
截图存放在本地忽略目录 `.local/console-ui-check/`:`login-locked.png`、`login-audit.png`、`scroll-boundary-fixed.png`,不含密码/Token 原文。
|
||||
|
||||
基础 `make test vet build console-check docs-check` 通过;真实 MySQL 存储全套通过,包含新保护专项 6 个子场景;独立 `make console-e2e` 通过。广泛 `make integration` 执行至 SDK 调度容量样本时,Docker 引擎读操作及旧服务健康请求均超时,样本未完成,因此这一次命令不记为通过。引擎自行恢复,部署完成,未重启 Docker 或其他项目。中断位置起使用相同脚本命令及参数在新的一次性数据库补跑。
|
||||
|
||||
尾段补跑完成:调度样本 6/6 触发并成功,流程样本 6/6 成功;其余 SDK、可运行示例、事务 Outbox/结果 Inbox、嵌入 HTTP 服务生命周期及真实进程恢复回归全部通过。覆盖租约恢复、ACK 丢响应、慢消费者、跨节点重连、优先级/并发控制、重试与截止恢复、定时计划恢复和流程暂停恢复。日志保留于 `.local/console-ui-check/security-integration-tail.log`;首段与该次补跑共同覆盖原集成脚本范围,不描述为单次全量命令通过。
|
||||
|
||||
39
docs/reports/credential-permissions-20261101.md
Normal file
39
docs/reports/credential-permissions-20261101.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 既有凭据权限管理回归
|
||||
|
||||
日期:2026-11-01。
|
||||
|
||||
## 功能范围
|
||||
|
||||
管理员通过控制台或 orbit-admin credential permissions 调整既有凭据的完整动作集合。SET_PERMISSIONS 使用 version=3 固定操作;旧动作仍使用原 v1/v2 格式,字段省略规则和历史摘要保持兼容。队列授权范围、Token 摘要及密文、有效期、旧代过渡期、资源 ID 均不改变。只更新动作集合与 revision,并将前后权限、主体、原因、固定回执同事务保存。
|
||||
|
||||
控制台入口仍只接受指定管理员会话与同源请求,业务 Bearer 不获得管理权限。页面使用精确动作勾选,默认保持原集合;高级核查、重试、审计与额度动作单独标识。未知动作、重复动作、通配符和空集合被拒绝;权限修改不能夹带有效期、过渡期或队列变化。
|
||||
|
||||
## 已执行验证
|
||||
|
||||
本批真库测试使用新建的一次性 MySQL 8.4 容器,核心与控制台测试各用独立数据库,没有对既有业务库执行故障注入。
|
||||
|
||||
| 验证 | 结果与边界 |
|
||||
| --- | --- |
|
||||
| 核心与历史凭据真库 race | 新权限前原 Token 调度请求为 403,授予后可实际创建计划,收回后当前/旧代 Token 均被拒绝;额外队列仍不可访问 |
|
||||
| 凭据保留 | 比对秘密摘要、密文、密钥编号、有效期、过渡截止、队列 JSON;只改变明确请求的动作与 revision |
|
||||
| 并发与重放 | 通过 performance_schema 确认实际数据库锁等待;同版本竞争仅一次生效,原操作重放无第二次审计,不恢复后来已收回的权限 |
|
||||
| 审计失败 | 隔离库注入审计写入失败,权限、revision 和幂等回执全部回滚 |
|
||||
| 兼容性 | v1/v2 固定 JSON/摘要不变;v1 → v3 → 旧回执重放 → v2 调整期限通过 |
|
||||
| 控制台 HTTP 与真库 | prepare/apply 的管理员、Cookie、Origin、Fetch Metadata、严格 JSON、注销后拒绝、两代 Token 与单队列隔离通过 |
|
||||
| 页面事件回归 | 执行实际 applications.js;默认精确集合、原操作重试、成功刷新、不读取 Token、其他模式及撤销状态隔离通过 |
|
||||
| 管理 CLI | 动作参数校验、禁止期限参数、0600 固定文件往返通过;另在隔离库通过真实 CLI 验证 prepare-only 前后状态不变,apply 与原文件重放仅增加一次 revision,队列范围保持 |
|
||||
| 标准检查 | make test、make vet、make build、make console-check、make docs-check 全部通过 |
|
||||
|
||||
## 复现入口与限制
|
||||
|
||||
```sh
|
||||
make test
|
||||
make vet
|
||||
make build
|
||||
make console-check
|
||||
make docs-check
|
||||
# ORBIT_TEST_MYSQL_DSN 从隔离环境私密配置加载,不指向共享业务库。
|
||||
go test -race -count=1 ./internal/storage/mysql -run 'TestIntegrationCredential|TestIntegrationConsole.*Permission|TestIntegrationConsoleApplications'
|
||||
```
|
||||
|
||||
没有新增数据库迁移、修改 SDK 协议或赋予普通凭据跨应用权限。上述验证不能替代生产多节点部署、权限变更审批和目标拓扑的正式验收;实际接入方授权记录位于工作区对接文档,不写入通用平台代码。
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ConsoleLogin consoleauth.LoginPolicy // ConsoleLogin 登录保护策略,所有服务节点使用一致配置。
|
||||
ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。
|
||||
CredentialKeyFile string // CredentialKeyFile 管理员 Token 保管密钥文件路径;所有服务节点共用同一密钥环。
|
||||
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
||||
@@ -39,7 +41,12 @@ type Config struct {
|
||||
|
||||
func Load() (Config, error) { return FromEnv(os.Getenv) }
|
||||
func FromEnv(get func(string) string) (Config, error) {
|
||||
loginPolicy, err := loginPolicyFromEnv(get)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c := Config{
|
||||
ConsoleLogin: loginPolicy,
|
||||
ConsoleApplicationAdmin: get("ORBIT_CONSOLE_APPLICATION_ADMIN"),
|
||||
CredentialKeyFile: get("ORBIT_CREDENTIAL_KEY_FILE"),
|
||||
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
|
||||
@@ -154,7 +161,6 @@ func FromEnv(get func(string) string) (Config, error) {
|
||||
}
|
||||
c.ArchiveTickInterval = d
|
||||
}
|
||||
var err error
|
||||
c.RetentionDefaults, err = retention.FromEnv(get)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
|
||||
57
internal/config/console_login.go
Normal file
57
internal/config/console_login.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func loginPolicyFromEnv(get func(string) string) (consoleauth.LoginPolicy, error) {
|
||||
p := consoleauth.DefaultLoginPolicy()
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
dst *int
|
||||
}{
|
||||
{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT", &p.FailureLimit},
|
||||
{"ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT", &p.AccountLimit},
|
||||
{"ORBIT_CONSOLE_LOGIN_IP_LIMIT", &p.IPLimit},
|
||||
{"ORBIT_CONSOLE_LOGIN_AUDIT_DAYS", &p.AuditDays},
|
||||
} {
|
||||
if raw := get(f.name); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("invalid %s", f.name)
|
||||
}
|
||||
*f.dst = n
|
||||
}
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
dst *time.Duration
|
||||
}{
|
||||
{"ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW", &p.FailureWindow},
|
||||
{"ORBIT_CONSOLE_LOGIN_LOCK_DURATION", &p.LockDuration},
|
||||
{"ORBIT_CONSOLE_LOGIN_RATE_WINDOW", &p.RateWindow},
|
||||
} {
|
||||
if raw := get(f.name); raw != "" {
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("invalid %s", f.name)
|
||||
}
|
||||
*f.dst = d
|
||||
}
|
||||
}
|
||||
if raw := get("ORBIT_CONSOLE_TRUSTED_PROXIES"); raw != "" {
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(item))
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("invalid ORBIT_CONSOLE_TRUSTED_PROXIES CIDR")
|
||||
}
|
||||
p.TrustedProxies = append(p.TrustedProxies, prefix.Masked())
|
||||
}
|
||||
}
|
||||
return p, p.Validate()
|
||||
}
|
||||
30
internal/config/console_login_test.go
Normal file
30
internal/config/console_login_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConsoleLoginConfiguration(t *testing.T) {
|
||||
p, err := loginPolicyFromEnv(func(string) string { return "" })
|
||||
if err != nil || p.FailureLimit != 5 || p.LockDuration != 15*time.Minute || p.AuditDays != 90 {
|
||||
t.Fatalf("defaults: %+v %v", p, err)
|
||||
}
|
||||
env := map[string]string{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT": "3", "ORBIT_CONSOLE_LOGIN_LOCK_DURATION": "20m", "ORBIT_CONSOLE_TRUSTED_PROXIES": "127.0.0.1/32, ::1/128"}
|
||||
p, err = loginPolicyFromEnv(func(k string) string { return env[k] })
|
||||
if err != nil || p.FailureLimit != 3 || len(p.TrustedProxies) != 2 {
|
||||
t.Fatalf("configured: %+v %v", p, err)
|
||||
}
|
||||
for k, v := range map[string]string{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT": "0", "ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT": "-1", "ORBIT_CONSOLE_LOGIN_IP_LIMIT": "10001", "ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW": "0s", "ORBIT_CONSOLE_LOGIN_LOCK_DURATION": "25h", "ORBIT_CONSOLE_LOGIN_RATE_WINDOW": "bad", "ORBIT_CONSOLE_LOGIN_AUDIT_DAYS": "0", "ORBIT_CONSOLE_TRUSTED_PROXIES": "all"} {
|
||||
t.Run(k, func(t *testing.T) {
|
||||
if _, err := loginPolicyFromEnv(func(name string) string {
|
||||
if name == k {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}); err == nil {
|
||||
t.Fatal("invalid policy accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ type AccessCredential struct {
|
||||
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil 上一代令牌实际截止,空表示无旧令牌。
|
||||
ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。
|
||||
Name string `json:"name"` // Name 凭据名称。
|
||||
Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。
|
||||
Revision uint64 `json:"revision,string"` // Revision 凭据管理变更的并发校验版本。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。
|
||||
Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。
|
||||
@@ -57,15 +57,16 @@ type ApplicationDetail struct {
|
||||
|
||||
// ApplicationInput 描述创建应用或变更凭据的管理员意图。
|
||||
type ApplicationInput struct {
|
||||
Kind string `json:"kind"` // Kind CREATE、ROTATE、SET_EXPIRY 或 FINALIZE。
|
||||
Kind string `json:"kind"` // Kind CREATE、ROTATE、SET_EXPIRY、FINALIZE 或 SET_PERMISSIONS。
|
||||
Application string `json:"application"` // Application 新应用名称,仅 CREATE 使用。
|
||||
Namespace string `json:"namespace"` // Namespace 新命名空间名称,仅 CREATE 使用。
|
||||
Environment string `json:"environment"` // Environment 新环境名称,仅 CREATE 使用。
|
||||
Queue string `json:"queue"` // Queue 初始队列名称,仅 CREATE 使用。
|
||||
Capabilities []string `json:"capabilities,omitempty"` // Capabilities 显式选择的业务权限组,仅 CREATE 使用。
|
||||
NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 轮换目标所属范围。
|
||||
CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 轮换目标凭据。
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 轮换时读取到的版本。
|
||||
Actions []string `json:"actions,omitempty"` // Actions 仅 SET_PERMISSIONS 使用,完整替换动作集合;不改变队列授权。
|
||||
NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 凭据变更目标所属范围。
|
||||
CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 变更目标凭据,权限修改不改变其身份。
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 变更前读取到的凭据版本,防止覆盖并发修改。
|
||||
ValidDays int `json:"valid_days"` // ValidDays 有效天数;0 为长期,1–365 为定期。
|
||||
GraceDays int `json:"grace_days,omitempty"` // GraceDays ROTATE 的旧令牌过渡天数,0–30;0 显式立即失效。
|
||||
Reason string `json:"reason"` // Reason 写入审计的变更原因。
|
||||
@@ -74,7 +75,7 @@ type ApplicationInput struct {
|
||||
// ApplicationOperation 是浏览器在提交前保存的固定操作;启用保管时仅保存 Token 密文。
|
||||
// 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。
|
||||
type ApplicationOperation struct {
|
||||
Version int `json:"version,omitempty"` // Version v2 支持长期令牌与过渡;旧固定操作未设置时沿用 v1。
|
||||
Version int `json:"version,omitempty"` // Version v3 仅用于权限变更;v2 支持长期令牌与过渡,未设置时沿用 v1。
|
||||
Input ApplicationInput `json:"input"` // Input 固定的管理员意图。
|
||||
OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。
|
||||
|
||||
73
internal/consoleauth/login_policy.go
Normal file
73
internal/consoleauth/login_policy.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package consoleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginPolicy 是所有节点统一配置的登录保护策略;计数及锁定保存在数据库。
|
||||
type LoginPolicy struct {
|
||||
FailureLimit int // FailureLimit 失败窗口内允许的连续失败次数,达到后锁定。
|
||||
FailureWindow time.Duration // FailureWindow 连续失败的统计窗口。
|
||||
LockDuration time.Duration // LockDuration 临时锁定时长,锁定期间请求不续期。
|
||||
AccountLimit int // AccountLimit 每个限流窗口最多接受的账号登录尝试数。
|
||||
IPLimit int // IPLimit 每个限流窗口最多接受的来源 IP 登录尝试数。
|
||||
RateWindow time.Duration // RateWindow 固定限流窗口;成功登录不清空流量计数。
|
||||
AuditDays int // AuditDays 登录审计保留天数,与业务数据策略独立。
|
||||
TrustedProxies []netip.Prefix // TrustedProxies 明确信任的反向代理网段;空时忽略所有转发头。
|
||||
}
|
||||
|
||||
func DefaultLoginPolicy() LoginPolicy {
|
||||
return LoginPolicy{FailureLimit: 5, FailureWindow: 15 * time.Minute, LockDuration: 15 * time.Minute, AccountLimit: 10, IPLimit: 30, RateWindow: time.Minute, AuditDays: 90}
|
||||
}
|
||||
func (p LoginPolicy) Validate() error {
|
||||
if p.FailureLimit < 1 || p.FailureLimit > 100 || p.AccountLimit < 1 || p.AccountLimit > 1000 || p.IPLimit < 1 || p.IPLimit > 10000 {
|
||||
return errors.New("console login limits out of range")
|
||||
}
|
||||
for _, d := range []time.Duration{p.FailureWindow, p.LockDuration, p.RateWindow} {
|
||||
if d < time.Second || d > 24*time.Hour {
|
||||
return errors.New("console login durations must be 1s–24h")
|
||||
}
|
||||
}
|
||||
if p.AuditDays < 1 || p.AuditDays > 3650 {
|
||||
return errors.New("console login audit retention must be 1–3650 days")
|
||||
}
|
||||
for _, n := range p.TrustedProxies {
|
||||
if !n.IsValid() {
|
||||
return errors.New("invalid console trusted proxy CIDR")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginBlocked 对外不区分账号是否存在,只给出拒绝类型和最短等待时间。
|
||||
type LoginBlocked struct {
|
||||
Locked bool // Locked 表示临时锁定,否则为请求限流。
|
||||
RetryAfter time.Duration // RetryAfter 按数据库时钟计算的剩余等待时间。
|
||||
}
|
||||
|
||||
func (*LoginBlocked) Error() string { return "console login temporarily blocked" }
|
||||
|
||||
// LoginAuditEntry 是脱敏的登录审计;从不记录密码、Cookie、Token 或它们的摘要。
|
||||
type LoginAuditEntry struct {
|
||||
ID uint64 `json:"id,string"` // ID 审计游标,按插入序号倒序分页。
|
||||
Username string `json:"username"` // Username 提交的有界登录名,不证明账号存在。
|
||||
RemoteIP string `json:"remote_ip"` // RemoteIP 经可信代理规则解析的来源地址。
|
||||
UserAgent string `json:"user_agent"` // UserAgent 截断为最多 512 字节的浏览器信息。
|
||||
Outcome string `json:"outcome"` // Outcome PENDING、SUCCESS、INVALID、LOCKED、ACCOUNT_LIMIT、IP_LIMIT 或 ERROR。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 尝试时间,UTC。
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"` // FinishedAt 完成时间;空表示尚未确认结果。
|
||||
RetryAt *time.Time `json:"retry_at,omitempty"` // RetryAt 拒绝时建议最早重试时间,UTC。
|
||||
}
|
||||
|
||||
// LoginAuditPage 固定每页 10 条,Next 是下一页排他上界;不承诺跨页快照。
|
||||
type LoginAuditPage struct {
|
||||
Items []LoginAuditEntry `json:"items"` // Items 当前页脱敏记录。
|
||||
Next string `json:"next,omitempty"` // Next 下一页游标,无更多时为空。
|
||||
}
|
||||
type LoginAuditor interface {
|
||||
ListLoginAudit(context.Context, auth.Token, string) (LoginAuditPage, error)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`)
|
||||
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
|
||||
// 已经存在的 v29/v30 表误当成迁移重复执行。
|
||||
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
||||
f.exec(`DROP TABLE orbit_console_login_audit,orbit_console_login_guards,orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
||||
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`)
|
||||
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_ciphertext, DROP COLUMN secret_key_id, DROP COLUMN secret_ciphertext`)
|
||||
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
|
||||
|
||||
@@ -943,3 +943,33 @@ ALTER TABLE orbit_subscriptions
|
||||
ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅',
|
||||
ADD KEY ix_subscription_active(namespace_id,retired_at),
|
||||
ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0));
|
||||
|
||||
-- 登录保护独立于业务凭据和命名空间;没有外键,不改变已有密码、会话或 Token。
|
||||
CREATE TABLE orbit_console_login_guards (
|
||||
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或 ACCOUNT,固定先 IP 后账号加锁',
|
||||
subject CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或规范账号身份的 SHA-256 十六进制键,不包含密码或 Token',
|
||||
window_start DATETIME(6) NOT NULL COMMENT '固定请求限流窗口起点,UTC',
|
||||
requests INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前窗口内已受理的尝试数,成功不归零',
|
||||
failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败窗口内的失败数,成功后归零',
|
||||
first_failed_at DATETIME(6) NULL COMMENT '失败窗口起点;空表示无连续失败',
|
||||
locked_until DATETIME(6) NULL COMMENT '临时锁定截止;锁内请求不延长此时间',
|
||||
expires_at DATETIME(6) NOT NULL COMMENT '非活动状态清理截止,晚于所有保护窗口',
|
||||
PRIMARY KEY(kind,subject),
|
||||
KEY ix_console_guard_expiry(expires_at),
|
||||
CONSTRAINT ck_console_guard_kind CHECK(kind IN ('IP','ACCOUNT'))
|
||||
) ENGINE=InnoDB COMMENT='跨节点持久登录限流及临时锁定状态';
|
||||
|
||||
CREATE TABLE orbit_console_login_audit (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审计序号,仅用于有界倒序分页',
|
||||
username VARCHAR(128) NOT NULL COMMENT '请求中的登录名,不证明账号存在',
|
||||
remote_ip VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '实际连接或受信代理解析出的规范 IP',
|
||||
user_agent VARCHAR(512) NOT NULL COMMENT '有界浏览器标识,不保存请求正文或认证头',
|
||||
outcome VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING/SUCCESS/INVALID/LOCKED/ACCOUNT_LIMIT/IP_LIMIT/ERROR',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '开始时间,UTC',
|
||||
finished_at DATETIME(6) NULL COMMENT '结果确认时间,空为未完成;异常中断由清理器标 ERROR',
|
||||
retry_at DATETIME(6) NULL COMMENT '被锁定或限流时的最早重试时间;否则为空',
|
||||
PRIMARY KEY(id),
|
||||
KEY ix_console_login_audit_time(created_at),
|
||||
KEY ix_console_login_audit_pending(outcome,created_at),
|
||||
CONSTRAINT ck_console_login_outcome CHECK(outcome IN ('PENDING','SUCCESS','INVALID','LOCKED','ACCOUNT_LIMIT','IP_LIMIT','ERROR'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='全局登录审计,不含密码、Cookie 或 Token,仅管理员可查询';
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
@@ -194,14 +195,17 @@ func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions,
|
||||
return p, p.Validate()
|
||||
}
|
||||
func validateApplicationInput(in consoleauth.ApplicationInput) error {
|
||||
if in.Kind != "SET_PERMISSIONS" && in.Actions != nil {
|
||||
return fault.Invalid("actions is only valid for SET_PERMISSIONS")
|
||||
}
|
||||
if in.ValidDays < 0 || in.ValidDays > 365 {
|
||||
return fault.Invalid("valid_days must be 0 (permanent) or 1-365")
|
||||
}
|
||||
if in.GraceDays < 0 || in.GraceDays > 30 || (in.Kind != "ROTATE" && in.GraceDays != 0) {
|
||||
return fault.Invalid("grace_days must be 0-30 for rotation only")
|
||||
}
|
||||
if in.Kind == "FINALIZE" && in.ValidDays != 0 {
|
||||
return fault.Invalid("finalize cannot set validity")
|
||||
if (in.Kind == "FINALIZE" || in.Kind == "SET_PERMISSIONS") && in.ValidDays != 0 {
|
||||
return fault.Invalid("this change cannot set validity")
|
||||
}
|
||||
if err := validCredentialReason(in.Reason); err != nil {
|
||||
return err
|
||||
@@ -213,12 +217,15 @@ func validateApplicationInput(in consoleauth.ApplicationInput) error {
|
||||
}
|
||||
_, err := applicationPermissions(in)
|
||||
return err
|
||||
case "ROTATE", "SET_EXPIRY", "FINALIZE":
|
||||
case "ROTATE", "SET_EXPIRY", "FINALIZE", "SET_PERMISSIONS":
|
||||
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 {
|
||||
return fault.Invalid("invalid rotation fields")
|
||||
return fault.Invalid("invalid credential change fields")
|
||||
}
|
||||
if in.Kind == "SET_PERMISSIONS" {
|
||||
return validateCredentialChangeInput(CredentialChangeInput{NamespaceID: in.NamespaceID, CredentialID: in.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: in.Kind, Actions: in.Actions, Reason: in.Reason})
|
||||
}
|
||||
default:
|
||||
return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY or FINALIZE")
|
||||
return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY, FINALIZE or SET_PERMISSIONS")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -227,6 +234,12 @@ func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token
|
||||
if err := validateApplicationInput(in); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if in.Kind == "SET_PERMISSIONS" {
|
||||
out.Version = 3
|
||||
// 固定排序副本,避免准备返回后由调用方改变切片或重放时重算意图。
|
||||
out.Input.Actions = append([]string(nil), in.Actions...)
|
||||
sort.Strings(out.Input.Actions)
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||
return err
|
||||
@@ -279,7 +292,8 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
|
||||
if err := validateApplicationInput(op.Input); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if (op.Version != 0 && op.Version != 2) || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||
validVersion := (op.Input.Kind == "SET_PERMISSIONS" && op.Version == 3) || (op.Input.Kind != "SET_PERMISSIONS" && (op.Version == 0 || op.Version == 2))
|
||||
if !validVersion || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||
return out, fault.Invalid("invalid fixed operation identity")
|
||||
}
|
||||
if (op.Input.ValidDays == 0) != (op.ExpiresAt == nil) || (op.Input.GraceDays == 0) != (op.PreviousValidUntil == nil) {
|
||||
@@ -293,7 +307,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
|
||||
if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID {
|
||||
return out, fault.Invalid("credential identity mismatch")
|
||||
}
|
||||
change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil}
|
||||
change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Actions: op.Input.Actions, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil}
|
||||
receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) {
|
||||
actor, err := s.admin(ctx, tx, token)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
func TestConsolePermissionInputAndFixedVersion(t *testing.T) {
|
||||
base := consoleauth.ApplicationInput{Kind: "SET_PERMISSIONS", NamespaceID: testID(t), CredentialID: testID(t), ExpectedRevision: 4, Reason: "enable scheduled tasks", Actions: []string{"tasks:submit", "schedules:read", "schedules:manage"}}
|
||||
if err := validateApplicationInput(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(*consoleauth.ApplicationInput)
|
||||
}{
|
||||
{"missing actions", func(in *consoleauth.ApplicationInput) { in.Actions = nil }},
|
||||
{"empty actions", func(in *consoleauth.ApplicationInput) { in.Actions = []string{} }},
|
||||
{"duplicate", func(in *consoleauth.ApplicationInput) { in.Actions = []string{"tasks:read", "tasks:read"} }},
|
||||
{"unknown", func(in *consoleauth.ApplicationInput) { in.Actions = []string{"unknown:action"} }},
|
||||
{"wildcard", func(in *consoleauth.ApplicationInput) { in.Actions = []string{"*"} }},
|
||||
{"expiry", func(in *consoleauth.ApplicationInput) { in.ValidDays = 7 }},
|
||||
{"grace", func(in *consoleauth.ApplicationInput) { in.GraceDays = 7 }},
|
||||
{"capability", func(in *consoleauth.ApplicationInput) { in.Capabilities = []string{"schedules"} }},
|
||||
{"queue", func(in *consoleauth.ApplicationInput) { in.Queue = "changed" }},
|
||||
{"missing version", func(in *consoleauth.ApplicationInput) { in.ExpectedRevision = 0 }},
|
||||
{"missing reason", func(in *consoleauth.ApplicationInput) { in.Reason = "" }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in := base
|
||||
tc.mutate(&in)
|
||||
if fault.Code(validateApplicationInput(in)) != "INVALID_ARGUMENT" {
|
||||
t.Fatal("invalid permission request accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} {
|
||||
in := base
|
||||
in.Kind = kind
|
||||
for _, actions := range [][]string{{"tasks:read"}, {}} {
|
||||
in.Actions = actions
|
||||
if fault.Code(validateApplicationInput(in)) != "INVALID_ARGUMENT" {
|
||||
t.Fatalf("%s accepted actions", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 拒绝旧格式携带新语义及新格式冒充旧操作;这里不得访问数据库。
|
||||
for _, version := range []int{0, 1, 2, 4} {
|
||||
op := consoleauth.ApplicationOperation{Version: version, Input: base, OperationID: testID(t), ApplicationID: testID(t), NamespaceID: base.NamespaceID, CredentialID: base.CredentialID}
|
||||
if _, err := NewConsoleApplicationStore(nil, "admin").ApplyApplication(context.Background(), auth.Token{}, op); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatalf("permission version %d accepted: %v", version, err)
|
||||
}
|
||||
}
|
||||
in := base
|
||||
in.Kind = "SET_EXPIRY"
|
||||
in.Actions = nil
|
||||
op := consoleauth.ApplicationOperation{Version: 3, Input: in, OperationID: testID(t), ApplicationID: testID(t), NamespaceID: in.NamespaceID, CredentialID: in.CredentialID}
|
||||
if _, err := NewConsoleApplicationStore(nil, "admin").ApplyApplication(context.Background(), auth.Token{}, op); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatal("v3 accepted legacy action", err)
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,20 @@ const (
|
||||
// ConsoleAuthStore 管理控制台的人类账号和 HttpOnly 会话。
|
||||
// 账号绑定一个已有的 Orbit 业务凭据,登录后沿用该凭据的权限范围;
|
||||
// 因此控制台登录不会改变 SDK 令牌,也不会绕过原有的 API 授权。
|
||||
type ConsoleAuthStore struct{ db *sql.DB }
|
||||
type ConsoleAuthStore struct {
|
||||
db *sql.DB
|
||||
policy consoleauth.LoginPolicy
|
||||
}
|
||||
|
||||
// NewConsoleAuthStore 创建控制台认证存储。
|
||||
func NewConsoleAuthStore(db *sql.DB) *ConsoleAuthStore { return &ConsoleAuthStore{db: db} }
|
||||
func NewConsoleAuthStore(db *sql.DB, policies ...consoleauth.LoginPolicy) *ConsoleAuthStore {
|
||||
policy := consoleauth.DefaultLoginPolicy()
|
||||
if len(policies) > 0 {
|
||||
policy = policies[0]
|
||||
}
|
||||
return &ConsoleAuthStore{db: db, policy: policy}
|
||||
}
|
||||
func (s *ConsoleAuthStore) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
||||
|
||||
var _ consoleauth.Store = (*ConsoleAuthStore)(nil)
|
||||
|
||||
@@ -108,59 +118,6 @@ func (s *ConsoleAuthStore) CreateConsoleUser(ctx context.Context, in ConsoleUser
|
||||
return ConsoleUser{ID: string(id), Username: in.Username, DisplayName: name}, nil
|
||||
}
|
||||
|
||||
// Login 校验账号密码并建立一个独立会话。失败时统一返回未认证,避免泄露账号存在性。
|
||||
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
|
||||
if s == nil || s.db == nil || username == "" || password == "" || len(password) > 1024 {
|
||||
return ConsoleLogin{}, fault.Unauthorized
|
||||
}
|
||||
var user ConsoleUser
|
||||
var userID core.ID
|
||||
var namespace, credential core.ID
|
||||
var hash string
|
||||
var enabled bool
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.password_hash,u.enabled,u.namespace_id,u.credential_id
|
||||
FROM orbit_console_users u
|
||||
JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id
|
||||
JOIN orbit_namespaces n ON n.id=u.namespace_id
|
||||
JOIN orbit_applications a ON a.id=n.application_id
|
||||
WHERE u.username=? AND c.revoked_at IS NULL
|
||||
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
||||
AND n.enabled=TRUE AND a.enabled=TRUE`, username).
|
||||
Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
|
||||
if err != nil || !enabled || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||
return ConsoleLogin{}, fault.Unauthorized
|
||||
}
|
||||
user.ID = string(userID)
|
||||
token, secretDigest, err := auth.Issue(namespace, credential)
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
var now time.Time
|
||||
if err := s.db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
now = now.UTC()
|
||||
expires := now.Add(ConsoleSessionLifetime)
|
||||
sessionID, err := core.NewID()
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
if len(userAgent) > 512 {
|
||||
userAgent = userAgent[:512]
|
||||
}
|
||||
if len(remoteAddr) > 64 {
|
||||
remoteAddr = remoteAddr[:64]
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO orbit_console_sessions
|
||||
(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr)
|
||||
VALUES (?,?,?,?,?,?,UTC_TIMESTAMP(6),?,?)`,
|
||||
sessionID, userID, namespace, credential, secretDigest, expires, userAgent, remoteAddr)
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
return ConsoleLogin{User: user, Token: token, ExpiresAt: expires}, nil
|
||||
}
|
||||
|
||||
// ValidateSession 检查会话是否仍然有效,并更新最近使用时间。
|
||||
// API 授权还会再次读取该会话对应凭据的权限,不信任浏览器提交的身份字段。
|
||||
func (s *ConsoleAuthStore) ValidateSession(ctx context.Context, token auth.Token) error {
|
||||
|
||||
337
internal/storage/mysql/console_login.go
Normal file
337
internal/storage/mysql/console_login.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// 不存在/停用的账号仍走相同成本校验,限流先于昂贵的密码计算。
|
||||
var consoleDummyHash = func() []byte {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("orbit-dummy-login-password"), consolePasswordCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hash
|
||||
}()
|
||||
|
||||
// loginGuard 对应持久限流行;所有字段的时间均由 MySQL UTC 时钟决定。
|
||||
type loginGuard struct {
|
||||
Kind string // Kind 为 IP 或 ACCOUNT,跨维度固定先 IP 后账号加锁。
|
||||
Subject string // Subject 为规范身份的 SHA-256,不含秘密。
|
||||
WindowStart time.Time // WindowStart 请求窗口起点。
|
||||
Requests int // Requests 已受理请求数,拒绝时不增加以免溢出。
|
||||
Failures int // Failures 连续失败计数。
|
||||
FirstFailed sql.NullTime // FirstFailed 失败窗口起点,空表示无失败。
|
||||
LockedUntil sql.NullTime // LockedUntil 锁定截止,空表示未锁定。
|
||||
}
|
||||
|
||||
func loginSubject(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
func loginText(s string, max int) string {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
for !utf8.ValidString(s) {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
func loginNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
||||
var now time.Time
|
||||
err := tx.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now)
|
||||
return now.UTC(), err
|
||||
}
|
||||
func loadLoginGuard(ctx context.Context, tx *sql.Tx, kind, key string, now time.Time) (loginGuard, error) {
|
||||
g := loginGuard{Kind: kind, Subject: key}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_login_guards(kind,subject,window_start,expires_at) VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE subject=VALUES(subject)`, kind, key, now, now.Add(48*time.Hour))
|
||||
if err != nil {
|
||||
return g, err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `SELECT window_start,requests,failures,first_failed_at,locked_until FROM orbit_console_login_guards WHERE kind=? AND subject=? FOR UPDATE`, kind, key).Scan(&g.WindowStart, &g.Requests, &g.Failures, &g.FirstFailed, &g.LockedUntil)
|
||||
return g, err
|
||||
}
|
||||
func saveLoginGuard(ctx context.Context, tx *sql.Tx, g loginGuard, now time.Time) error {
|
||||
_, err := tx.ExecContext(ctx, `UPDATE orbit_console_login_guards SET window_start=?,requests=?,failures=?,first_failed_at=?,locked_until=?,expires_at=? WHERE kind=? AND subject=?`, g.WindowStart, g.Requests, g.Failures, g.FirstFailed, g.LockedUntil, now.Add(48*time.Hour), g.Kind, g.Subject)
|
||||
return err
|
||||
}
|
||||
func (g *loginGuard) resetExpired(now time.Time, p consoleauth.LoginPolicy) {
|
||||
if !now.Before(g.WindowStart.Add(p.RateWindow)) {
|
||||
g.WindowStart = now
|
||||
g.Requests = 0
|
||||
}
|
||||
if g.LockedUntil.Valid && !now.Before(g.LockedUntil.Time) {
|
||||
g.LockedUntil = sql.NullTime{}
|
||||
g.FirstFailed = sql.NullTime{}
|
||||
g.Failures = 0
|
||||
}
|
||||
if !g.LockedUntil.Valid && g.FirstFailed.Valid && !now.Before(g.FirstFailed.Time.Add(p.FailureWindow)) {
|
||||
g.FirstFailed = sql.NullTime{}
|
||||
g.Failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Login 先在短事务中占用账号/IP 请求预算,再在事务外计算 bcrypt。
|
||||
// 最终事务重新锁定账号保护行,失败、锁定、成功会话及审计原子提交;并发校验不能穿过已生效的锁定。
|
||||
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return ConsoleLogin{}, errors.New("console authentication unavailable")
|
||||
}
|
||||
if err := s.policy.Validate(); err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
if username == "" || len(username) > 128 || !utf8.ValidString(username) || password == "" || len(password) > 1024 {
|
||||
return ConsoleLogin{}, fault.Unauthorized
|
||||
}
|
||||
ip, err := netip.ParseAddr(remoteAddr)
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, fault.Invalid("invalid login source address")
|
||||
}
|
||||
remoteAddr = ip.Unmap().String()
|
||||
userAgent = loginText(userAgent, 512)
|
||||
var user ConsoleUser
|
||||
var userID, namespace, credential core.ID
|
||||
var hash string
|
||||
var enabled bool
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,enabled,namespace_id,credential_id FROM orbit_console_users WHERE username=?`, username).Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
exists := err == nil
|
||||
// 使用已存在账号的 ID,避免 Unicode 等价拼写绕过 MySQL 登录名匹配对应的限额。
|
||||
accountKey := loginSubject("unknown:" + username)
|
||||
if exists {
|
||||
accountKey = loginSubject("user:" + string(userID))
|
||||
}
|
||||
var attemptID int64
|
||||
var blocked *consoleauth.LoginBlocked
|
||||
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
blocked = nil
|
||||
now, err := loginNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ipGuard, err := loadLoginGuard(ctx, tx, "IP", loginSubject(remoteAddr), now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ipGuard.resetExpired(now, s.policy)
|
||||
outcome := "PENDING"
|
||||
var retryAt *time.Time
|
||||
reject := func(reason string, until time.Time, locked bool) {
|
||||
outcome = reason
|
||||
retryAt = &until
|
||||
blocked = &consoleauth.LoginBlocked{Locked: locked, RetryAfter: until.Sub(now)}
|
||||
}
|
||||
if ipGuard.Requests >= s.policy.IPLimit {
|
||||
reject("IP_LIMIT", ipGuard.WindowStart.Add(s.policy.RateWindow), false)
|
||||
} else {
|
||||
ipGuard.Requests++
|
||||
account, err := loadLoginGuard(ctx, tx, "ACCOUNT", accountKey, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account.resetExpired(now, s.policy)
|
||||
if account.LockedUntil.Valid {
|
||||
reject("LOCKED", account.LockedUntil.Time, true)
|
||||
} else if account.Requests >= s.policy.AccountLimit {
|
||||
reject("ACCOUNT_LIMIT", account.WindowStart.Add(s.policy.RateWindow), false)
|
||||
} else {
|
||||
account.Requests++
|
||||
}
|
||||
if err = saveLoginGuard(ctx, tx, account, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = saveLoginGuard(ctx, tx, ipGuard, now); err != nil {
|
||||
return err
|
||||
}
|
||||
var finished *time.Time
|
||||
if blocked != nil {
|
||||
finished = &now
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_login_audit(username,remote_ip,user_agent,outcome,created_at,finished_at,retry_at) VALUES(?,?,?,?,?,?,?)`, username, remoteAddr, userAgent, outcome, now, finished, retryAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attemptID, err = result.LastInsertId()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
if blocked != nil {
|
||||
return ConsoleLogin{}, blocked
|
||||
}
|
||||
checkHash := consoleDummyHash
|
||||
if exists && enabled {
|
||||
checkHash = []byte(hash)
|
||||
}
|
||||
valid := bcrypt.CompareHashAndPassword(checkHash, []byte(password)) == nil && exists && enabled
|
||||
var out ConsoleLogin
|
||||
var rejected error
|
||||
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
out = ConsoleLogin{}
|
||||
rejected = nil
|
||||
now, err := loginNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard, err := loadLoginGuard(ctx, tx, "ACCOUNT", accountKey, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard.resetExpired(now, s.policy)
|
||||
outcome := "INVALID"
|
||||
var retryAt *time.Time
|
||||
if guard.LockedUntil.Valid {
|
||||
outcome = "LOCKED"
|
||||
until := guard.LockedUntil.Time
|
||||
retryAt = &until
|
||||
rejected = &consoleauth.LoginBlocked{Locked: true, RetryAfter: until.Sub(now)}
|
||||
} else {
|
||||
stillValid := valid
|
||||
if stillValid {
|
||||
var currentHash string
|
||||
// 密码/账号/关联权限在最终事务中复查;停用、换密和失效凭据不能签发新会话。
|
||||
err = tx.QueryRowContext(ctx, `SELECT u.password_hash FROM orbit_console_users u JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id JOIN orbit_namespaces n ON n.id=u.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE u.id=? AND u.namespace_id=? AND u.credential_id=? AND u.enabled=TRUE AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, userID, namespace, credential).Scan(¤tHash)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
stillValid = err == nil && currentHash == hash
|
||||
}
|
||||
if stillValid {
|
||||
token, digest, err := auth.Issue(namespace, credential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessionID, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := now.Add(ConsoleSessionLifetime)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_console_sessions(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr) VALUES(?,?,?,?,?,?,?,?,?)`, sessionID, userID, namespace, credential, digest, expires, now, userAgent, remoteAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.ID = string(userID)
|
||||
out = ConsoleLogin{User: user, Token: token, ExpiresAt: expires}
|
||||
outcome = "SUCCESS"
|
||||
guard.Failures = 0
|
||||
guard.FirstFailed = sql.NullTime{}
|
||||
guard.LockedUntil = sql.NullTime{}
|
||||
} else {
|
||||
rejected = fault.Unauthorized
|
||||
if !guard.FirstFailed.Valid {
|
||||
guard.FirstFailed = sql.NullTime{Time: now, Valid: true}
|
||||
}
|
||||
guard.Failures++
|
||||
if guard.Failures >= s.policy.FailureLimit {
|
||||
until := now.Add(s.policy.LockDuration)
|
||||
guard.LockedUntil = sql.NullTime{Time: until, Valid: true}
|
||||
outcome = "LOCKED"
|
||||
retryAt = &until
|
||||
rejected = &consoleauth.LoginBlocked{Locked: true, RetryAfter: s.policy.LockDuration}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = saveLoginGuard(ctx, tx, guard, now); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE orbit_console_login_audit SET outcome=?,finished_at=?,retry_at=? WHERE id=? AND outcome='PENDING'`, outcome, now, retryAt, attemptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return errors.New("login audit result not confirmed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ConsoleLogin{}, err
|
||||
}
|
||||
return out, rejected
|
||||
}
|
||||
|
||||
// CleanupLoginSecurity 每批最多清理 500 行,过期时间有索引,不触碰业务审计或有效锁定。
|
||||
func (s *ConsoleAuthStore) CleanupLoginSecurity(ctx context.Context) error {
|
||||
if err := s.policy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, q := range []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{`UPDATE orbit_console_login_audit SET outcome='ERROR',finished_at=UTC_TIMESTAMP(6) WHERE outcome='PENDING' AND created_at<UTC_TIMESTAMP(6)-INTERVAL 10 MINUTE ORDER BY created_at LIMIT 500`, nil},
|
||||
{`DELETE FROM orbit_console_login_guards WHERE expires_at<UTC_TIMESTAMP(6) ORDER BY expires_at LIMIT 500`, nil},
|
||||
{`DELETE FROM orbit_console_login_audit WHERE created_at<UTC_TIMESTAMP(6)-INTERVAL ? DAY ORDER BY created_at LIMIT 500`, []any{s.policy.AuditDays}},
|
||||
} {
|
||||
if _, err := s.db.ExecContext(ctx, q.sql, q.args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListLoginAudit 仅沿用部署指定的单管理员权限;普通会话及 SDK Token 不能读取全局登录记录。
|
||||
func (s *ConsoleApplicationStore) ListLoginAudit(ctx context.Context, token auth.Token, before string) (consoleauth.LoginAuditPage, error) {
|
||||
out := consoleauth.LoginAuditPage{Items: []consoleauth.LoginAuditEntry{}}
|
||||
var cursor uint64
|
||||
if before != "" {
|
||||
var err error
|
||||
cursor, err = strconv.ParseUint(before, 10, 64)
|
||||
if err != nil || cursor == 0 {
|
||||
return out, fault.Invalid("invalid login audit cursor")
|
||||
}
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
q := `SELECT id,username,remote_ip,user_agent,outcome,created_at,finished_at,retry_at FROM orbit_console_login_audit`
|
||||
args := []any{}
|
||||
if cursor != 0 {
|
||||
q += ` WHERE id<?`
|
||||
args = append(args, cursor)
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, q+` ORDER BY id DESC LIMIT 11`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
out.Items = []consoleauth.LoginAuditEntry{}
|
||||
for rows.Next() {
|
||||
var e consoleauth.LoginAuditEntry
|
||||
if err = rows.Scan(&e.ID, &e.Username, &e.RemoteIP, &e.UserAgent, &e.Outcome, &e.CreatedAt, &e.FinishedAt, &e.RetryAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, e)
|
||||
}
|
||||
if len(out.Items) > 10 {
|
||||
out.Items = out.Items[:10]
|
||||
out.Next = strconv.FormatUint(out.Items[9].ID, 10)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
262
internal/storage/mysql/console_login_integration_test.go
Normal file
262
internal/storage/mysql/console_login_integration_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationConsoleLoginProtection(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 16, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
username := "login-" + string(testID(t))
|
||||
password := "login-protection-test-12345"
|
||||
var boot BootstrapResult
|
||||
if err = Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { boot = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := consoleauth.DefaultLoginPolicy()
|
||||
p.FailureLimit = 3
|
||||
p.IPLimit = 1000
|
||||
p.AccountLimit = 100
|
||||
p.LockDuration = time.Second
|
||||
first, second := NewConsoleAuthStore(db, p), NewConsoleAuthStore(db, p)
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var userID string
|
||||
if err = db.QueryRowContext(ctx, `SELECT LOWER(HEX(id)) FROM orbit_console_users WHERE username=?`, username).Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := loginSubject("user:" + userID)
|
||||
clear := func() { exec(`DELETE FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key) }
|
||||
login := func(s *ConsoleAuthStore, pass, ip string) error {
|
||||
t.Helper()
|
||||
_, err := s.Login(ctx, username, pass, "login-regression", ip)
|
||||
return err
|
||||
}
|
||||
expectBlocked := func(err error, locked bool) {
|
||||
t.Helper()
|
||||
var b *consoleauth.LoginBlocked
|
||||
if !errors.As(err, &b) || b.Locked != locked || b.RetryAfter <= 0 {
|
||||
t.Fatalf("expected block locked=%v got %v", locked, err)
|
||||
}
|
||||
}
|
||||
t.Run("failure_success_reset_cross_node_and_expiry", func(t *testing.T) {
|
||||
if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = login(second, password, "192.0.2.11"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
expectBlocked(login(second, "wrong", "192.0.2.11"), true)
|
||||
var until, after time.Time
|
||||
if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&until); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectBlocked(login(NewConsoleAuthStore(db, p), password, "192.0.2.12"), true)
|
||||
if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&after); err != nil || !until.Equal(after) {
|
||||
t.Fatal("lock was extended", err)
|
||||
}
|
||||
// 使用数据库自身时钟等待真实截止,重建存储实例不能提前解除锁定。
|
||||
for {
|
||||
var elapsed bool
|
||||
if err = db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)>=?", until).Scan(&elapsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if elapsed {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if err = login(second, password, "192.0.2.12"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("concurrent_failures_serialize_and_lock", func(t *testing.T) {
|
||||
clear()
|
||||
policy := p
|
||||
policy.LockDuration = time.Minute
|
||||
a, b := NewConsoleAuthStore(db, policy), NewConsoleAuthStore(db, policy)
|
||||
results := make(chan error, 6)
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
s := a
|
||||
if i%2 == 1 {
|
||||
s = b
|
||||
}
|
||||
results <- login(s, "wrong", "192.0.2.20")
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
invalid, blocked := 0, 0
|
||||
for err := range results {
|
||||
var v *consoleauth.LoginBlocked
|
||||
if errors.As(err, &v) && v.Locked {
|
||||
blocked++
|
||||
} else if errors.Is(err, fault.Unauthorized) {
|
||||
invalid++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if invalid != 2 || blocked != 4 {
|
||||
t.Fatalf("invalid=%d blocked=%d", invalid, blocked)
|
||||
}
|
||||
expectBlocked(login(a, password, "192.0.2.21"), true)
|
||||
var count int
|
||||
if err = db.QueryRowContext(ctx, `SELECT failures FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&count); err != nil || count != 3 {
|
||||
t.Fatalf("failure count=%d %v", count, err)
|
||||
}
|
||||
})
|
||||
t.Run("account_and_ip_limits", func(t *testing.T) {
|
||||
clear()
|
||||
policy := p
|
||||
policy.AccountLimit = 2
|
||||
s := NewConsoleAuthStore(db, policy)
|
||||
for _, ip := range []string{"192.0.2.30", "192.0.2.31"} {
|
||||
if err = login(s, password, ip); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
expectBlocked(login(NewConsoleAuthStore(db, policy), password, "192.0.2.32"), false)
|
||||
clear()
|
||||
policy.IPLimit = 2
|
||||
s = NewConsoleAuthStore(db, policy)
|
||||
for i := 0; i < 2; i++ {
|
||||
_, err = s.Login(ctx, "unknown-"+string(testID(t)), "wrong", "test", "192.0.2.40")
|
||||
if !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
expectBlocked(login(s, password, "192.0.2.40"), false)
|
||||
exec(`UPDATE orbit_console_login_guards SET window_start=UTC_TIMESTAMP(6)-INTERVAL 2 MINUTE WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.40"))
|
||||
if err = login(s, password, "192.0.2.40"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("unknown_account_and_failure_window", func(t *testing.T) {
|
||||
unknown := "unknown-" + string(testID(t))
|
||||
policy := p
|
||||
policy.FailureLimit = 2
|
||||
s := NewConsoleAuthStore(db, policy)
|
||||
_, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50")
|
||||
if !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50")
|
||||
expectBlocked(err, true)
|
||||
clear()
|
||||
if err = login(first, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec(`UPDATE orbit_console_login_guards SET failures=2,first_failed_at=UTC_TIMESTAMP(6)-INTERVAL 1 HOUR WHERE kind='ACCOUNT' AND subject=?`, key)
|
||||
if err = login(second, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("audit_failure_cannot_issue_session", func(t *testing.T) {
|
||||
clear()
|
||||
var before, after int
|
||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&before)
|
||||
trigger := "login_audit_" + string(testID(t))
|
||||
exec(`CREATE TRIGGER ` + trigger + ` BEFORE UPDATE ON orbit_console_login_audit FOR EACH ROW BEGIN IF NEW.username='` + username + `' AND NEW.outcome='SUCCESS' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test audit unavailable'; END IF; END`)
|
||||
defer exec("DROP TRIGGER " + trigger)
|
||||
if err = login(first, password, "192.0.2.60"); err == nil {
|
||||
t.Fatal("audit failure issued session")
|
||||
}
|
||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&after)
|
||||
if before != after {
|
||||
t.Fatal("session survived failed audit")
|
||||
}
|
||||
})
|
||||
t.Run("audit_admin_pagination_secrets_and_cleanup", func(t *testing.T) {
|
||||
clear()
|
||||
result, err := first.Login(ctx, username, password, "test", "192.0.2.70")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, _ := auth.Parse(result.Token)
|
||||
sdk, _ := auth.Parse(boot.Token)
|
||||
store := NewConsoleApplicationStore(db, username)
|
||||
if _, err = store.ListLoginAudit(ctx, sdk, ""); !errors.Is(err, fault.Forbidden) {
|
||||
t.Fatal("SDK read login audit", err)
|
||||
}
|
||||
if _, err = NewConsoleApplicationStore(db, "another-admin").ListLoginAudit(ctx, session, ""); !errors.Is(err, fault.Forbidden) {
|
||||
t.Fatal("non-admin read audit", err)
|
||||
}
|
||||
page, err := store.ListLoginAudit(ctx, session, "")
|
||||
if err != nil || len(page.Items) != 10 || page.Next == "" {
|
||||
t.Fatalf("page: %+v %v", page, err)
|
||||
}
|
||||
next, err := store.ListLoginAudit(ctx, session, page.Next)
|
||||
if err != nil || len(next.Items) == 0 || next.Items[0].ID >= page.Items[9].ID {
|
||||
t.Fatal("audit pagination", err)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT(username,remote_ip,user_agent,outcome) FROM orbit_console_login_audit WHERE username=?`, username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err = rows.Scan(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(value, password) || strings.Contains(value, result.Token) {
|
||||
t.Fatal("audit contains secret")
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 91 DAY WHERE username=? AND outcome='INVALID'`, username)
|
||||
exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 11 MINUTE WHERE username=? AND outcome='PENDING'`, username)
|
||||
exec(`UPDATE orbit_console_login_guards SET expires_at=UTC_TIMESTAMP(6)-INTERVAL 1 DAY WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.70"))
|
||||
if err = first.CleanupLoginSecurity(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_login_audit WHERE username=? AND (outcome='PENDING' OR created_at<UTC_TIMESTAMP(6)-INTERVAL 90 DAY)`, username).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatal("audit cleanup", n, err)
|
||||
}
|
||||
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatal("foreign keys", n, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
231
internal/storage/mysql/console_permissions_integration_test.go
Normal file
231
internal/storage/mysql/console_permissions_integration_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||
)
|
||||
|
||||
// 从真实管理员 HTTP 入口确认权限变更不会生成、回看或轮换 Token,
|
||||
// 且旧操作重放仍核对当前会话,原队列范围和过渡中的两代秘密保持不变。
|
||||
func TestIntegrationConsolePermissionChange(t *testing.T) {
|
||||
ctx, db := overlapDB(t)
|
||||
ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{43}, 32)) + `"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "permissions-" + string(testID(t))
|
||||
password := "permissions-test-password-12345"
|
||||
var initial BootstrapResult
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: name, ConsolePassword: password}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authStore := NewConsoleAuthStore(db)
|
||||
login, err := authStore.Login(ctx, name, password, "test", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _ := auth.Parse(login.Token)
|
||||
sdk, _ := auth.Parse(initial.Token)
|
||||
store := NewConsoleApplicationStore(db, name, ring)
|
||||
server := httpserver.NewWithConsoleAuth("", nil, authStore)
|
||||
httpserver.RegisterConsoleApplications(server, authStore, store)
|
||||
request := func(path string, value any, token string, status int) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Orbit-Console", "1")
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
req.AddCookie(&http.Cookie{Name: "orbit_console_session", Value: token})
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(res, req)
|
||||
if res.Code != status {
|
||||
t.Fatalf("%s status=%d wanted=%d", path, res.Code, status)
|
||||
}
|
||||
if res.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("management response cacheable")
|
||||
}
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
prepare := func(in consoleauth.ApplicationInput) consoleauth.ApplicationOperation {
|
||||
t.Helper()
|
||||
var op consoleauth.ApplicationOperation
|
||||
if err := json.Unmarshal(request("/console/applications/prepare", in, login.Token, 200), &op); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return op
|
||||
}
|
||||
apply := func(op consoleauth.ApplicationOperation) consoleauth.ApplicationReceipt {
|
||||
t.Helper()
|
||||
var receipt consoleauth.ApplicationReceipt
|
||||
if err := json.Unmarshal(request("/console/applications/apply", op, login.Token, 200), &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
created := prepare(consoleauth.ApplicationInput{Kind: "CREATE", Application: "managed-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Capabilities: []string{"worker"}, ValidDays: 30, Reason: "create permission fixture"})
|
||||
apply(created)
|
||||
// 限定测试凭据队列,验证补动作不会扩大为命名空间通配范围。
|
||||
queues, _ := json.Marshal([]string{string(created.QueueID)})
|
||||
if _, err := db.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',CAST(? AS JSON)) WHERE namespace_id=? AND id=?`, queues, created.NamespaceID, created.CredentialID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotation := prepare(consoleauth.ApplicationInput{Kind: "ROTATE", NamespaceID: created.NamespaceID, CredentialID: created.CredentialID, ExpectedRevision: 1, ValidDays: 30, GraceDays: 7, Reason: "establish token overlap fixture"})
|
||||
apply(rotation)
|
||||
current, _ := auth.Parse(rotation.Token)
|
||||
previous, _ := auth.Parse(created.Token)
|
||||
detail, err := store.GetApplication(ctx, admin, created.NamespaceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(detail.Credentials) != 1 {
|
||||
t.Fatal("fixture credential missing")
|
||||
}
|
||||
oldPermissions := detail.Credentials[0].Permissions
|
||||
actions := append(append([]string(nil), oldPermissions.Actions...), "schedules:read", "schedules:manage")
|
||||
in := consoleauth.ApplicationInput{Kind: "SET_PERMISSIONS", NamespaceID: created.NamespaceID, CredentialID: created.CredentialID, ExpectedRevision: 2, Actions: actions, Reason: "allow scheduled tasks"}
|
||||
for _, unauthorized := range []auth.Token{sdk, {}} {
|
||||
if _, err := store.PrepareApplication(ctx, unauthorized, in); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("non-admin prepare accepted", err)
|
||||
}
|
||||
}
|
||||
if _, err := NewConsoleApplicationStore(db, "other-admin", ring).PrepareApplication(ctx, admin, in); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("other admin prepare accepted", err)
|
||||
}
|
||||
request("/console/applications/prepare", in, initial.Token, 401)
|
||||
op := prepare(in)
|
||||
if op.Version != 3 || op.Token != "" || op.ExpiresAt != nil || op.PreviousValidUntil != nil || !sort.StringsAreSorted(op.Input.Actions) {
|
||||
t.Fatal("permission prepare produced wrong fixed format or secret")
|
||||
}
|
||||
// 准备只返回新意图,不变更当前权限;原秘密仍在,但尚无计划读取授权。
|
||||
schedules, err := NewScheduleStore(db, testID(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, token := range []auth.Token{current, previous} {
|
||||
if _, err := schedules.List(ctx, token, created.QueueID, schedule.PageInput{Limit: 1}); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("prepare granted permission", err)
|
||||
}
|
||||
}
|
||||
type preserved struct {
|
||||
Digest, Previous, Cipher, Queues []byte
|
||||
Key sql.NullString
|
||||
Expires, Until sql.NullTime
|
||||
}
|
||||
snapshot := func() preserved {
|
||||
t.Helper()
|
||||
var p preserved
|
||||
if err := db.QueryRowContext(ctx, `SELECT secret_digest,previous_secret_digest,secret_ciphertext,JSON_EXTRACT(permissions,'$.queues'),secret_key_id,expires_at,previous_valid_until FROM orbit_credentials WHERE namespace_id=? AND id=?`, created.NamespaceID, created.CredentialID).Scan(&p.Digest, &p.Previous, &p.Cipher, &p.Queues, &p.Key, &p.Expires, &p.Until); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
before := snapshot()
|
||||
if len(before.Cipher) == 0 || len(before.Previous) == 0 || !before.Expires.Valid || !before.Until.Valid {
|
||||
t.Fatal("fixture does not exercise vault, expiry and overlap")
|
||||
}
|
||||
for _, mutate := range []func(*consoleauth.ApplicationOperation){
|
||||
func(v *consoleauth.ApplicationOperation) { v.Token = rotation.Token },
|
||||
func(v *consoleauth.ApplicationOperation) { v.QueueID = created.QueueID },
|
||||
func(v *consoleauth.ApplicationOperation) { v.ApplicationID = testID(t) },
|
||||
func(v *consoleauth.ApplicationOperation) { v.ExpiresAt = rotation.ExpiresAt },
|
||||
func(v *consoleauth.ApplicationOperation) { v.Version = 2 },
|
||||
} {
|
||||
bad := op
|
||||
mutate(&bad)
|
||||
request("/console/applications/apply", bad, login.Token, 400)
|
||||
}
|
||||
if _, err := store.ApplyApplication(ctx, sdk, op); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("SDK apply accepted", err)
|
||||
}
|
||||
receipt := apply(op)
|
||||
if receipt.Deduplicated || receipt.Revision != 3 || receipt.CredentialID != created.CredentialID {
|
||||
t.Fatal("unexpected permission receipt")
|
||||
}
|
||||
replay := apply(op)
|
||||
if !replay.Deduplicated || replay.Revision != receipt.Revision {
|
||||
t.Fatal("original operation did not replay")
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot()) {
|
||||
t.Fatal("permission change modified secret, vault, expiry, overlap or queues")
|
||||
}
|
||||
for _, token := range []auth.Token{current, previous} {
|
||||
if _, err := schedules.List(ctx, token, created.QueueID, schedule.PageInput{Limit: 1}); err != nil {
|
||||
t.Fatal("original token did not gain requested action", err)
|
||||
}
|
||||
if err := NewTaskStore(db).AuthorizeSubmit(ctx, token); err != nil {
|
||||
t.Fatal("existing action was lost", err)
|
||||
}
|
||||
if _, err := schedules.List(ctx, token, testID(t), schedule.PageInput{Limit: 1}); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("queue scope expanded", err)
|
||||
}
|
||||
}
|
||||
request("/console/applications/prepare", in, login.Token, 409)
|
||||
changed := op
|
||||
changed.Input.Actions = append([]string(nil), op.Input.Actions[:len(op.Input.Actions)-1]...)
|
||||
request("/console/applications/apply", changed, login.Token, 409)
|
||||
detail, err = store.GetApplication(ctx, admin, created.NamespaceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Credentials[0].Revision != 3 || !reflect.DeepEqual(detail.Credentials[0].Permissions.Actions, op.Input.Actions) || !reflect.DeepEqual(detail.Credentials[0].Permissions.Queues, oldPermissions.Queues) {
|
||||
t.Fatal("detail does not reflect exact action replacement")
|
||||
}
|
||||
var actor, reason, beforeAudit, afterAudit string
|
||||
if err := db.QueryRowContext(ctx, "SELECT actor_id,reason,CAST(`before` AS CHAR),CAST(`after` AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND operation_id=? AND action='credential.set_permissions'", created.NamespaceID, op.OperationID).Scan(&actor, &reason, &beforeAudit, &afterAudit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actor != "console:"+login.User.ID || reason != in.Reason || !strings.Contains(afterAudit, "schedules:manage") || strings.Contains(beforeAudit, "schedules:manage") {
|
||||
t.Fatal("permission audit lacks actor or exact before/after")
|
||||
}
|
||||
var auditCount, tokenViews int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND operation_id=?`, created.NamespaceID, op.OperationID).Scan(&auditCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action LIKE 'console.credential.token.%'`, created.NamespaceID).Scan(&tokenViews); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auditCount != 1 || tokenViews != 0 {
|
||||
t.Fatal("permission operation duplicated audit or accessed token")
|
||||
}
|
||||
for _, secret := range []string{created.Token, rotation.Token} {
|
||||
if strings.Contains(beforeAudit, secret) || strings.Contains(afterAudit, secret) {
|
||||
t.Fatal("audit leaked token")
|
||||
}
|
||||
}
|
||||
if err := authStore.RevokeSession(ctx, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request("/console/applications/apply", op, login.Token, 401)
|
||||
if _, err := store.ApplyApplication(ctx, admin, op); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("revoked session replay accepted", err)
|
||||
}
|
||||
var foreignKeys int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
|
||||
t.Fatalf("foreign keys=%d err=%v", foreignKeys, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
func TestCredentialPermissionOperationValidation(t *testing.T) {
|
||||
base := CredentialChangeInput{NamespaceID: testID(t), CredentialID: testID(t), ExpectedRevision: 1, Action: credentialPermissionsAction, Reason: "replace explicit actions", Actions: []string{"schedules:manage", "tasks:read"}}
|
||||
if err := validateCredentialChangeInput(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, actions := range [][]string{nil, {}, {""}, {"*"}, {"schedules:*"}, {"unknown"}, {"tasks:read", "tasks:read"}, {" tasks:read"}} {
|
||||
bad := base
|
||||
bad.Actions = actions
|
||||
if err := validateCredentialChangeInput(bad); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatalf("actions %q accepted: %v", actions, err)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"validity", "grace"} {
|
||||
bad := base
|
||||
if field == "validity" {
|
||||
bad.ValidFor = time.Hour
|
||||
} else {
|
||||
bad.GraceFor = time.Hour
|
||||
}
|
||||
if err := validateCredentialChangeInput(bad); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatalf("permission change accepted %s: %v", field, err)
|
||||
}
|
||||
}
|
||||
op := CredentialOperation{Version: 3, OperationID: testID(t), NamespaceID: base.NamespaceID, CredentialID: base.CredentialID, ExpectedRevision: 1, Action: credentialPermissionsAction, Reason: base.Reason, Actions: base.Actions}
|
||||
if err := ValidateCredentialOperation(op); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func(*CredentialOperation){
|
||||
func(v *CredentialOperation) { v.Version = 1 },
|
||||
func(v *CredentialOperation) { v.Version = 2 },
|
||||
func(v *CredentialOperation) { v.Actions = []string{"tasks:read", "schedules:manage"} },
|
||||
func(v *CredentialOperation) { v.Actions = []string{"tasks:read", "tasks:read"} },
|
||||
func(v *CredentialOperation) { v.Actions = nil },
|
||||
func(v *CredentialOperation) { v.Token = "a token is forbidden" },
|
||||
func(v *CredentialOperation) { v.ExpiresAt = timePtr(time.Now().Add(time.Hour)) },
|
||||
func(v *CredentialOperation) { v.PreviousValidUntil = timePtr(time.Now().Add(time.Hour)) },
|
||||
func(v *CredentialOperation) { v.Action = credentialExpiryAction },
|
||||
} {
|
||||
bad := op
|
||||
mutate(&bad)
|
||||
if err := ValidateCredentialOperation(bad); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatalf("invalid permission operation accepted: %v", err)
|
||||
}
|
||||
}
|
||||
// 旧固定文件的精确字段、序列化原文及摘要保持不变;包括旧轮换的秘密字段。
|
||||
type legacyOperation struct {
|
||||
Version int `json:"version"`
|
||||
OperationID core.ID `json:"operation_id"`
|
||||
NamespaceID core.ID `json:"namespace_id"`
|
||||
CredentialID core.ID `json:"credential_id"`
|
||||
ExpectedRevision uint64 `json:"expected_revision"`
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason"`
|
||||
Token string `json:"token,omitempty"`
|
||||
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
for _, version := range []int{1, 2} {
|
||||
for _, action := range []string{credentialRotateAction, credentialRevokeAction, credentialExpiryAction, credentialFinalizeAction} {
|
||||
if version == 1 && action != credentialRotateAction && action != credentialRevokeAction {
|
||||
continue
|
||||
}
|
||||
old := legacyOperation{Version: version, OperationID: testID(t), NamespaceID: base.NamespaceID, CredentialID: base.CredentialID, ExpectedRevision: 1, Action: action, Reason: "legacy frozen shape"}
|
||||
if action == credentialRotateAction {
|
||||
old.Token, _, _ = auth.Issue(base.NamespaceID, base.CredentialID)
|
||||
old.ExpiresAt = timePtr(time.Now().Add(time.Hour))
|
||||
}
|
||||
raw, _ := json.Marshal(old)
|
||||
var current CredentialOperation
|
||||
if err := json.Unmarshal(raw, ¤t); err != nil || ValidateCredentialOperation(current) != nil {
|
||||
t.Fatal("legacy operation no longer valid", err)
|
||||
}
|
||||
currentRaw, _ := json.Marshal(current)
|
||||
oldDigest, _ := requestDigest(old)
|
||||
currentDigest, _ := requestDigest(current)
|
||||
if !bytes.Equal(raw, currentRaw) || oldDigest != currentDigest {
|
||||
t.Fatal("legacy JSON or digest changed")
|
||||
}
|
||||
for _, actions := range [][]string{{}, {"tasks:read"}} {
|
||||
current.Actions = actions
|
||||
if err := ValidateCredentialOperation(current); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatal("legacy operation accepted actions", err)
|
||||
}
|
||||
bad := base
|
||||
bad.Action, bad.Actions = action, actions
|
||||
if err := validateCredentialChangeInput(bad); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||
t.Fatal("legacy preparation accepted actions", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationCredentialPermissions(t *testing.T) {
|
||||
ctx, db := overlapDB(t)
|
||||
ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{43}, 32)) + `"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var initial BootstrapResult
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: "permissions-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", ValidFor: 48 * time.Hour}, func(v BootstrapResult) error { initial = v; return nil }, ring); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldToken, _ := auth.Parse(initial.Token)
|
||||
tasks := NewTaskStore(db)
|
||||
otherQueue, err := service.New(tasks).CreateQueue(ctx, oldToken, string(testID(t)), service.QueueInput{Name: "outside-scope", MaxConcurrency: 1, RatePerSecond: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 隔离测试夹具先收窄队列,证明添加动作不能隐式恢复命名空间通配范围。
|
||||
queues, _ := json.Marshal([]string{string(initial.QueueID)})
|
||||
if _, err := db.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',CAST(? AS JSON)) WHERE namespace_id=? AND id=?`, queues, initial.NamespaceID, initial.CredentialID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schedules, err := NewScheduleStore(db, testID(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scheduleAPI := schedule.New(schedules)
|
||||
plan := schedule.CreateInput{Name: "permission-check", Paused: true, Definition: schedule.Definition{
|
||||
Rule: schedule.Rule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: time.Now().UTC().Add(time.Hour), IntervalMS: 60000},
|
||||
Policy: schedule.Policy{Misfire: "SKIP", Overlap: "PARALLEL", CatchupLimit: 1, CatchupWindowMS: 30000, GraceMS: 1000},
|
||||
Task: service.SubmitInput{QueueID: initial.QueueID, Handler: "permission.check", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)},
|
||||
}}
|
||||
if _, err := scheduleAPI.Create(ctx, oldToken, string(testID(t)), plan); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("schedule permission unexpectedly present before change", err)
|
||||
}
|
||||
prepare := func(revision uint64, actions []string) CredentialOperation {
|
||||
t.Helper()
|
||||
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: revision, Action: credentialPermissionsAction, Reason: "explicit complete action set", Actions: actions})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return op
|
||||
}
|
||||
rotate, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: 24 * time.Hour, GraceFor: time.Hour, Reason: "test two retained generations"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = ApplyCredentialOperation(ctx, db, rotate, ring); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 快照只在测试内比较,绝不把摘要、密文或 Token 打到失败日志。
|
||||
protected := func() string {
|
||||
t.Helper()
|
||||
var out string
|
||||
if err := db.QueryRowContext(ctx, `SELECT CAST(JSON_OBJECT('namespace',HEX(namespace_id),'id',HEX(id),'name',name,'current',HEX(secret_digest),'previous',HEX(previous_secret_digest),'key',secret_key_id,'cipher',HEX(secret_ciphertext),'expiry',expires_at,'grace',previous_valid_until,'revoked',revoked_at,'queues',JSON_EXTRACT(permissions,'$.queues'),'created',created_at) AS CHAR) FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
frozen := protected()
|
||||
actions := []string{"tasks:submit", "schedules:read", "queues:read", "schedules:manage"}
|
||||
op := prepare(2, actions)
|
||||
actions[0] = "tasks:cancel" // Prepare 必须复制输入,不共享调用方数组。
|
||||
if op.Version != 3 || op.Token != "" || !sort.StringsAreSorted(op.Actions) || op.Actions[len(op.Actions)-1] != "tasks:submit" {
|
||||
t.Fatal("permission operation did not freeze a canonical secret-free set")
|
||||
}
|
||||
trigger := "permissions_audit_" + string(testID(t))
|
||||
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action='credential.set_permissions' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='permission audit failure test'; END IF; END"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
|
||||
if _, err := ApplyCredentialOperation(ctx, db, op); err == nil {
|
||||
t.Fatal("audit failure accepted")
|
||||
}
|
||||
failed, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil || !reflect.DeepEqual(failed, before) || protected() != frozen {
|
||||
t.Fatal("failed audit did not roll back credential state", err)
|
||||
}
|
||||
var rows int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_idempotency_keys WHERE namespace_id=? AND id=?`, initial.NamespaceID, op.OperationID).Scan(&rows); err != nil || rows != 0 {
|
||||
t.Fatal("failed audit kept operation identity", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, err := ApplyCredentialOperation(ctx, db, op) // 无 vault 也不能清除既有密文。
|
||||
if err != nil || grant.Revision != 3 || grant.Deduplicated || protected() != frozen {
|
||||
t.Fatal("permission grant changed protected credential state", err)
|
||||
}
|
||||
newToken, _ := auth.Parse(rotate.Token)
|
||||
assertActions := func(want auth.Permissions) {
|
||||
t.Helper()
|
||||
for _, token := range []auth.Token{oldToken, newToken} {
|
||||
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
||||
p, err := authenticate(ctx, tx, token)
|
||||
if err == nil && !reflect.DeepEqual(p, want) {
|
||||
t.Error("token authentication did not reload exact current permissions")
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("preserved token no longer authenticates", err)
|
||||
}
|
||||
if _, err := tasks.GetQueue(ctx, token, initial.QueueID); err != nil {
|
||||
t.Fatal("original queue unavailable", err)
|
||||
}
|
||||
if _, err := tasks.GetQueue(ctx, token, otherQueue.Queue.ID); fault.Code(err) != "NOT_FOUND" {
|
||||
t.Fatal("permission change expanded queue scope", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
after, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil || !reflect.DeepEqual(after.Permissions.Actions, op.Actions) || !reflect.DeepEqual(after.Permissions.Queues, before.Permissions.Queues) {
|
||||
t.Fatal("exact permissions status incorrect", err)
|
||||
}
|
||||
assertActions(after.Permissions)
|
||||
created, err := scheduleAPI.Create(ctx, oldToken, string(testID(t)), plan)
|
||||
if err != nil {
|
||||
t.Fatal("new permission did not immediately allow real schedule creation", err)
|
||||
}
|
||||
if _, err := scheduleAPI.Get(ctx, newToken, created.Schedule.ID); err != nil {
|
||||
t.Fatal("current token did not receive new schedule read permission", err)
|
||||
}
|
||||
forbiddenPlan := plan
|
||||
forbiddenPlan.Name = "outside-queue"
|
||||
forbiddenPlan.Definition.Task.QueueID = otherQueue.Queue.ID
|
||||
if _, err := scheduleAPI.Create(ctx, newToken, string(testID(t)), forbiddenPlan); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("new schedule action bypassed preserved queue scope", err)
|
||||
}
|
||||
var auditBefore, auditAfter []byte
|
||||
if err := db.QueryRowContext(ctx, "SELECT `before`,`after` FROM orbit_audit_events WHERE namespace_id=? AND operation_id=?", initial.NamespaceID, op.OperationID).Scan(&auditBefore, &auditAfter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, raw := range [][]byte{auditBefore, auditAfter} {
|
||||
var audit struct {
|
||||
Permissions auth.Permissions `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &audit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := before.Permissions
|
||||
if i == 1 {
|
||||
want = after.Permissions
|
||||
}
|
||||
if !reflect.DeepEqual(audit.Permissions, want) || bytes.Contains(raw, []byte("secret_")) || bytes.Contains(raw, []byte(rotate.Token)) {
|
||||
t.Fatal("permissions audit missing exact scope or leaked secret")
|
||||
}
|
||||
}
|
||||
if replay, err := ApplyCredentialOperation(ctx, db, op); err != nil || !replay.Deduplicated || replay.Revision != grant.Revision {
|
||||
t.Fatal("permission replay changed receipt", err)
|
||||
}
|
||||
tampered := op
|
||||
tampered.Actions = []string{"queues:read"}
|
||||
if _, err := ApplyCredentialOperation(ctx, db, tampered); fault.Code(err) != "IDEMPOTENCY_CONFLICT" {
|
||||
t.Fatal("changed frozen actions accepted", err)
|
||||
}
|
||||
wrongScope := op
|
||||
wrongScope.NamespaceID = testID(t)
|
||||
if _, err := ApplyCredentialOperation(ctx, db, wrongScope); fault.Code(err) != "NOT_FOUND" {
|
||||
t.Fatal("cross namespace credential accepted", err)
|
||||
}
|
||||
remove := prepare(3, []string{"queues:read"})
|
||||
if _, err := ApplyCredentialOperation(ctx, db, remove); err != nil || protected() != frozen {
|
||||
t.Fatal("permission removal changed protected state", err)
|
||||
}
|
||||
assertActions(auth.Permissions{Actions: remove.Actions, Queues: before.Permissions.Queues})
|
||||
for _, token := range []auth.Token{oldToken, newToken} {
|
||||
if err := tasks.AuthorizeSubmit(ctx, token); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("removed permission still usable", err)
|
||||
}
|
||||
if _, err := scheduleAPI.Get(ctx, token, created.Schedule.ID); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("removed schedule permission still usable", err)
|
||||
}
|
||||
}
|
||||
if _, err := ApplyCredentialOperation(ctx, db, op); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertActions(auth.Permissions{Actions: remove.Actions, Queues: before.Permissions.Queues})
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND resource_id=? AND action='credential.set_permissions'`, initial.NamespaceID, initial.CredentialID).Scan(&rows); err != nil || rows != 2 {
|
||||
t.Fatal("replay duplicated permission audit", err)
|
||||
}
|
||||
var foreignKeys int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
|
||||
t.Fatal("schema acquired a foreign key", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationCredentialPermissionsLegacyReplay(t *testing.T) {
|
||||
ctx, db := overlapDB(t)
|
||||
var initial BootstrapResult
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: "permission-legacy-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, Reason: "legacy v1 replay"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old.Version = 1
|
||||
if _, err := ApplyCredentialOperation(ctx, db, old); err != nil {
|
||||
t.Fatal("legacy v1 no longer applies", err)
|
||||
}
|
||||
permissions, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 2, Action: credentialPermissionsAction, Actions: []string{"queues:read"}, Reason: "new permission revision"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ApplyCredentialOperation(ctx, db, permissions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replay, err := ApplyCredentialOperation(ctx, db, old); err != nil || !replay.Deduplicated || replay.Revision != 2 {
|
||||
t.Fatal("legacy receipt no longer replays after v3", err)
|
||||
}
|
||||
expiry, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 3, Action: credentialExpiryAction, Reason: "legacy v2 still generated"})
|
||||
if err != nil || expiry.Version != 2 {
|
||||
t.Fatal("old action changed fixed format", err)
|
||||
}
|
||||
if _, err := ApplyCredentialOperation(ctx, db, expiry); err != nil {
|
||||
t.Fatal("legacy v2 no longer applies", err)
|
||||
}
|
||||
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil || status.Revision != 4 || status.ExpiresAt != nil || !reflect.DeepEqual(status.Permissions.Actions, permissions.Actions) {
|
||||
t.Fatal("legacy changes overwrote new permissions", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 竞争由专用事务持有真实凭据行锁,确认每个请求实际到达 InnoDB 等待后再放行。
|
||||
func competeCredentialPermissions(t *testing.T, ctx context.Context, db *sql.DB, namespace, credential core.ID, operations ...func() error) []error {
|
||||
t.Helper()
|
||||
guard, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer guard.Rollback()
|
||||
var connection uint64
|
||||
if err := guard.QueryRowContext(ctx, `SELECT CONNECTION_ID()`).Scan(&connection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var revision uint64
|
||||
if err := guard.QueryRowContext(ctx, `SELECT revision FROM orbit_credentials WHERE namespace_id=? AND id=? FOR UPDATE`, namespace, credential).Scan(&revision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := make([]chan error, len(operations))
|
||||
for i, operation := range operations {
|
||||
results[i] = make(chan error, 1)
|
||||
go func(ch chan error, run func() error) { ch <- run() }(results[i], operation)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case early := <-results[i]:
|
||||
t.Fatalf("permission competitor returned before DB lock: %v", early)
|
||||
default:
|
||||
}
|
||||
var waiting int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT w.REQUESTING_THREAD_ID) FROM performance_schema.data_lock_waits w JOIN performance_schema.threads b ON b.THREAD_ID=w.BLOCKING_THREAD_ID WHERE b.PROCESSLIST_ID=?`, connection).Scan(&waiting); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if waiting == i+1 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("only %d/%d requests reached credential lock", waiting, i+1)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if err := guard.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errs := make([]error, len(operations))
|
||||
for i, ch := range results {
|
||||
select {
|
||||
case errs[i] = <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func TestIntegrationCredentialPermissionsConcurrency(t *testing.T) {
|
||||
ctx, db := overlapDB(t)
|
||||
var initial BootstrapResult
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: "permission-race-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepare := func(revision uint64, action string) CredentialOperation {
|
||||
t.Helper()
|
||||
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: revision, Action: credentialPermissionsAction, Reason: "permission CAS race", Actions: []string{action}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return op
|
||||
}
|
||||
op := prepare(1, "queues:read")
|
||||
var receipts [2]CredentialChangeReceipt
|
||||
errs := competeCredentialPermissions(t, ctx, db, initial.NamespaceID, initial.CredentialID,
|
||||
func() (err error) { receipts[0], err = ApplyCredentialOperation(ctx, db, op); return },
|
||||
func() (err error) { receipts[1], err = ApplyCredentialOperation(ctx, db, op); return })
|
||||
if errs[0] != nil || errs[1] != nil || receipts[0].Deduplicated == receipts[1].Deduplicated || receipts[0].Revision != 2 || receipts[1].Revision != 2 {
|
||||
t.Fatalf("concurrent replay: %v", errs)
|
||||
}
|
||||
left, right := prepare(2, "tasks:read"), prepare(2, "schedules:read")
|
||||
errs = competeCredentialPermissions(t, ctx, db, initial.NamespaceID, initial.CredentialID,
|
||||
func() error { _, err := ApplyCredentialOperation(ctx, db, left); return err },
|
||||
func() error { _, err := ApplyCredentialOperation(ctx, db, right); return err })
|
||||
if !((errs[0] == nil && fault.Code(errs[1]) == "STATE_CONFLICT") || (errs[1] == nil && fault.Code(errs[0]) == "STATE_CONFLICT")) {
|
||||
t.Fatalf("permission CAS did not select exactly one winner: %v", errs)
|
||||
}
|
||||
winner := left
|
||||
if errs[1] == nil {
|
||||
winner = right
|
||||
}
|
||||
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil || status.Revision != 3 || !reflect.DeepEqual(status.Permissions.Actions, winner.Actions) {
|
||||
t.Fatal("CAS did not persist winner", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='credential.set_permissions'`, initial.NamespaceID).Scan(&count); err != nil || count != 2 {
|
||||
t.Fatal("losing CAS or replay created audit", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -18,10 +19,12 @@ import (
|
||||
|
||||
const (
|
||||
credentialOperationVersion = 2
|
||||
credentialPermissionsVersion = 3
|
||||
credentialRotateAction = "ROTATE"
|
||||
credentialRevokeAction = "REVOKE"
|
||||
credentialExpiryAction = "SET_EXPIRY"
|
||||
credentialFinalizeAction = "FINALIZE"
|
||||
credentialPermissionsAction = "SET_PERMISSIONS"
|
||||
credentialOperationName = "credential.change.v1"
|
||||
)
|
||||
|
||||
@@ -46,6 +49,8 @@ type CredentialStatus struct {
|
||||
NamespaceEnabled bool `json:"namespace_enabled"`
|
||||
// ApplicationEnabled 所属应用是否启用。
|
||||
ApplicationEnabled bool `json:"application_enabled"`
|
||||
// Permissions 当前精确授权集合;队列范围只读,不允许凭据变更扩大或替换。
|
||||
Permissions auth.Permissions `json:"permissions"`
|
||||
}
|
||||
|
||||
// CredentialChangeInput 描述一次待固定的管理变更。
|
||||
@@ -57,7 +62,7 @@ type CredentialChangeInput struct {
|
||||
CredentialID core.ID
|
||||
// ExpectedRevision 准备操作时观察到的版本。
|
||||
ExpectedRevision uint64
|
||||
// Action 为 ROTATE、REVOKE、SET_EXPIRY 或 FINALIZE。
|
||||
// Action 为 ROTATE、REVOKE、SET_EXPIRY、FINALIZE 或 SET_PERMISSIONS。
|
||||
Action string
|
||||
// GraceFor 轮换时旧令牌的共存时长;零为立即失效,最多 30 天。
|
||||
GraceFor time.Duration
|
||||
@@ -65,6 +70,8 @@ type CredentialChangeInput struct {
|
||||
ValidFor time.Duration
|
||||
// Reason 必填审计原因。
|
||||
Reason string
|
||||
// Actions 仅 SET_PERMISSIONS 使用的完整非空动作集合,替换旧集合但保留队列范围。
|
||||
Actions []string
|
||||
}
|
||||
|
||||
// CredentialOperation 是可安全重放的私有操作文件内容。
|
||||
@@ -90,6 +97,8 @@ type CredentialOperation struct {
|
||||
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
|
||||
// ExpiresAt 轮换/调整有效期后的固定绝对时间;v2 为空表示长期,重放不能重新计算。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
// Actions v3 SET_PERMISSIONS 的排序完整动作集合;省略保证旧 v1/v2 摘要不变。
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialChangeReceipt 是服务端保存并可重复返回的脱敏结果。
|
||||
@@ -127,18 +136,25 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential
|
||||
return out, err
|
||||
}
|
||||
var expires, revoked, previousUntil sql.NullTime
|
||||
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
|
||||
var permissions []byte
|
||||
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled,c.permissions
|
||||
FROM orbit_credentials c
|
||||
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||
JOIN orbit_applications a ON a.id=n.application_id
|
||||
WHERE c.namespace_id=? AND c.id=?`, namespace, credential).
|
||||
Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled)
|
||||
Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled, &permissions)
|
||||
if err == sql.ErrNoRows {
|
||||
return out, fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(permissions, &out.Permissions); err != nil {
|
||||
return out, fmt.Errorf("stored credential permissions are invalid: %w", err)
|
||||
}
|
||||
if err := out.Permissions.Validate(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.NamespaceID, out.CredentialID = namespace, credential
|
||||
out.ExpiresAt = nullableTime(expires)
|
||||
out.PreviousValidUntil = nullableTime(previousUntil)
|
||||
@@ -182,6 +198,11 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
|
||||
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
||||
}
|
||||
out.Version = credentialOperationVersion
|
||||
if in.Action == credentialPermissionsAction {
|
||||
out.Version = credentialPermissionsVersion
|
||||
out.Actions = append([]string(nil), in.Actions...)
|
||||
sort.Strings(out.Actions)
|
||||
}
|
||||
out.NamespaceID = in.NamespaceID
|
||||
out.CredentialID = in.CredentialID
|
||||
out.ExpectedRevision = in.ExpectedRevision
|
||||
@@ -197,7 +218,7 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
|
||||
// ValidateCredentialOperation validates the exact private operation shape.
|
||||
// It is exported so the admin CLI can reject tampered files before opening a DB.
|
||||
func ValidateCredentialOperation(op CredentialOperation) error {
|
||||
if (op.Version != 1 && op.Version != credentialOperationVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||
if (op.Version != 1 && op.Version != credentialOperationVersion && op.Version != credentialPermissionsVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||
return fault.Invalid("credential operation identity or version is invalid")
|
||||
}
|
||||
if op.ExpectedRevision == 0 || !validCredentialAction(op.Action) {
|
||||
@@ -206,6 +227,16 @@ func ValidateCredentialOperation(op CredentialOperation) error {
|
||||
if err := validCredentialReason(op.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if op.Action == credentialPermissionsAction {
|
||||
if op.Version != credentialPermissionsVersion || !sort.StringsAreSorted(op.Actions) {
|
||||
return fault.Invalid("permission operation requires v3 and sorted actions")
|
||||
}
|
||||
if err := validateCredentialActions(op.Actions); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if op.Version == credentialPermissionsVersion || op.Actions != nil {
|
||||
return fault.Invalid("actions are only valid for v3 SET_PERMISSIONS")
|
||||
}
|
||||
if op.Version == 1 && ((op.Action != credentialRotateAction && op.Action != credentialRevokeAction) || op.PreviousValidUntil != nil || (op.Action == credentialRotateAction && op.ExpiresAt == nil)) {
|
||||
return fault.Invalid("legacy operation requires its original expiry and immediate rotation")
|
||||
}
|
||||
@@ -255,13 +286,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
var name string
|
||||
var revision uint64
|
||||
var expires, revoked, previousUntil sql.NullTime
|
||||
var storedPermissions []byte
|
||||
var namespaceEnabled, applicationEnabled bool
|
||||
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
|
||||
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled,c.permissions
|
||||
FROM orbit_credentials c
|
||||
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||
JOIN orbit_applications a ON a.id=n.application_id
|
||||
WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID).
|
||||
Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled)
|
||||
Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled, &storedPermissions)
|
||||
if err == sql.ErrNoRows {
|
||||
return fault.NotFound
|
||||
}
|
||||
@@ -297,6 +329,24 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
out.ExpiresAt = nullableTime(expires)
|
||||
out.PreviousValidUntil = nullableTime(previousUntil)
|
||||
switch op.Action {
|
||||
case credentialPermissionsAction:
|
||||
var previous auth.Permissions
|
||||
if err := json.Unmarshal(storedPermissions, &previous); err != nil {
|
||||
return fmt.Errorf("stored credential permissions are invalid: %w", err)
|
||||
}
|
||||
if err := previous.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
current := auth.Permissions{Actions: op.Actions, Queues: previous.Queues}
|
||||
before["permissions"], after["permissions"] = previous, current
|
||||
actions, err := json.Marshal(op.Actions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 只替换动作路径;队列 JSON、两代摘要、密文、有效期和过渡截止均原样保留。
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',CAST(? AS JSON)),revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, actions, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
return err
|
||||
}
|
||||
case credentialRotateAction:
|
||||
parsed, _ := auth.Parse(op.Token)
|
||||
// 仍在过渡期时不覆盖上一代,避免把尚未切换的服务突然踢下线。
|
||||
@@ -486,12 +536,33 @@ func validateCredentialChangeInput(in CredentialChangeInput) error {
|
||||
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
||||
return fault.Invalid("validity must be zero (permanent) or between one hour and one year")
|
||||
}
|
||||
if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction) && in.ValidFor != 0 {
|
||||
if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction || in.Action == credentialPermissionsAction) && in.ValidFor != 0 {
|
||||
return fault.Invalid("this operation cannot set validity")
|
||||
}
|
||||
if in.GraceFor < 0 || in.GraceFor > 30*24*time.Hour || (in.Action != credentialRotateAction && in.GraceFor != 0) {
|
||||
return fault.Invalid("rotation grace must be between zero and 30 days")
|
||||
}
|
||||
if in.Action == credentialPermissionsAction {
|
||||
return validateCredentialActions(in.Actions)
|
||||
}
|
||||
if in.Actions != nil {
|
||||
return fault.Invalid("actions are only valid for SET_PERMISSIONS")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCredentialActions 复用认证动作白名单,并拒绝重复项,避免全量替换的歧义。
|
||||
func validateCredentialActions(actions []string) error {
|
||||
if err := (auth.Permissions{Actions: actions, Queues: []string{"*"}}).Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]bool, len(actions))
|
||||
for _, action := range actions {
|
||||
if seen[action] {
|
||||
return fault.Invalid("duplicate permission action")
|
||||
}
|
||||
seen[action] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -516,5 +587,5 @@ func timePtr(v time.Time) *time.Time {
|
||||
}
|
||||
|
||||
func validCredentialAction(action string) bool {
|
||||
return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction
|
||||
return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction || action == credentialPermissionsAction
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
||||
//go:embed baseline.sql
|
||||
var freshInstallBaseline string
|
||||
|
||||
const freshInstallBaselineVersion uint64 = 34
|
||||
const freshInstallBaselineVersion uint64 = 35
|
||||
|
||||
type Migration struct {
|
||||
Version uint64
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 登录保护独立于业务凭据和命名空间;没有外键,不改变已有密码、会话或 Token。
|
||||
CREATE TABLE orbit_console_login_guards (
|
||||
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或 ACCOUNT,固定先 IP 后账号加锁',
|
||||
subject CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或规范账号身份的 SHA-256 十六进制键,不包含密码或 Token',
|
||||
window_start DATETIME(6) NOT NULL COMMENT '固定请求限流窗口起点,UTC',
|
||||
requests INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前窗口内已受理的尝试数,成功不归零',
|
||||
failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败窗口内的失败数,成功后归零',
|
||||
first_failed_at DATETIME(6) NULL COMMENT '失败窗口起点;空表示无连续失败',
|
||||
locked_until DATETIME(6) NULL COMMENT '临时锁定截止;锁内请求不延长此时间',
|
||||
expires_at DATETIME(6) NOT NULL COMMENT '非活动状态清理截止,晚于所有保护窗口',
|
||||
PRIMARY KEY(kind,subject),
|
||||
KEY ix_console_guard_expiry(expires_at),
|
||||
CONSTRAINT ck_console_guard_kind CHECK(kind IN ('IP','ACCOUNT'))
|
||||
) ENGINE=InnoDB COMMENT='跨节点持久登录限流及临时锁定状态';
|
||||
|
||||
CREATE TABLE orbit_console_login_audit (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审计序号,仅用于有界倒序分页',
|
||||
username VARCHAR(128) NOT NULL COMMENT '请求中的登录名,不证明账号存在',
|
||||
remote_ip VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '实际连接或受信代理解析出的规范 IP',
|
||||
user_agent VARCHAR(512) NOT NULL COMMENT '有界浏览器标识,不保存请求正文或认证头',
|
||||
outcome VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING/SUCCESS/INVALID/LOCKED/ACCOUNT_LIMIT/IP_LIMIT/ERROR',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '开始时间,UTC',
|
||||
finished_at DATETIME(6) NULL COMMENT '结果确认时间,空为未完成;异常中断由清理器标 ERROR',
|
||||
retry_at DATETIME(6) NULL COMMENT '被锁定或限流时的最早重试时间;否则为空',
|
||||
PRIMARY KEY(id),
|
||||
KEY ix_console_login_audit_time(created_at),
|
||||
KEY ix_console_login_audit_pending(outcome,created_at),
|
||||
CONSTRAINT ck_console_login_outcome CHECK(outcome IN ('PENDING','SUCCESS','INVALID','LOCKED','ACCOUNT_LIMIT','IP_LIMIT','ERROR'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='全局登录审计,不含密码、Cookie 或 Token,仅管理员可查询';
|
||||
@@ -147,7 +147,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
||||
indexes := func() []string {
|
||||
t.Helper()
|
||||
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_console_login_audit','orbit_console_login_guards','orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -64,6 +67,12 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
||||
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": true})
|
||||
})
|
||||
mux.HandleFunc("POST /console/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := http.NewCrossOriginProtection().Check(r); err != nil {
|
||||
writeConsoleJSON(w, http.StatusForbidden, map[string]any{"error": map[string]string{"code": "PERMISSION_DENIED", "message": "不允许跨站登录请求"}})
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
|
||||
|
||||
if consoleAuth == nil {
|
||||
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "console authentication is not configured"}})
|
||||
return
|
||||
@@ -78,10 +87,23 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), remoteAddress(r))
|
||||
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), loginRemoteAddress(r, consoleAuth))
|
||||
if err != nil {
|
||||
// 账号不存在、密码错误、账号停用和绑定凭据失效统一返回,避免账号枚举。
|
||||
var blocked *consoleauth.LoginBlocked
|
||||
if errors.As(err, &blocked) {
|
||||
seconds := max(1, int(math.Ceil(blocked.RetryAfter.Seconds())))
|
||||
code, message := "LOGIN_RATE_LIMITED", "登录尝试过于频繁,请稍后重试"
|
||||
if blocked.Locked {
|
||||
code, message = "LOGIN_LOCKED", "连续登录失败,已临时锁定,请稍后重试"
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(seconds))
|
||||
writeConsoleJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"code": code, "message": message, "retry_after_seconds": seconds}})
|
||||
} else if errors.Is(err, fault.Unauthorized) {
|
||||
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
|
||||
} else {
|
||||
// 存储或审计不可用时不发 Cookie,不将基础设施故障伪装成密码错误。
|
||||
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "登录服务暂不可用,请稍后重试"}})
|
||||
}
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: login.Token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: login.ExpiresAt, MaxAge: int(time.Until(login.ExpiresAt).Seconds())})
|
||||
|
||||
@@ -12,6 +12,26 @@
|
||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
|
||||
}
|
||||
let loginAuditCursor = '', loginAuditPage = 1, loginAuditLoading = false;
|
||||
async function loadLoginAudit(first = false) {
|
||||
if (loginAuditLoading) return;
|
||||
loginAuditLoading = true;
|
||||
$('login-audit-first').disabled = true; $('login-audit-next').disabled = true;
|
||||
text('login-audit-status', '正在读取…');
|
||||
try {
|
||||
const cursor = first ? '' : loginAuditCursor;
|
||||
const page = extractData(await request(`/console/auth/audit${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`));
|
||||
const labels = { SUCCESS: '登录成功', INVALID: '账号或密码错误', LOCKED: '临时锁定', ACCOUNT_LIMIT: '账号限流', IP_LIMIT: 'IP 限流', PENDING: '校验中', ERROR: '未完成 / 服务异常' };
|
||||
$('login-audit-rows').innerHTML = (page.items || []).map(row => `<tr><td>${escapeHTML(formatTime(row.created_at))}</td><td>${escapeHTML(row.username)}</td><td>${escapeHTML(row.remote_ip)}</td><td>${escapeHTML(labels[row.outcome] || row.outcome)}</td><td>${escapeHTML(formatTime(row.retry_at))}</td></tr>`).join('');
|
||||
loginAuditPage = first ? 1 : loginAuditPage + 1; loginAuditCursor = page.next || '';
|
||||
text('login-audit-page', `第 ${loginAuditPage} 页`); text('login-audit-status', page.items?.length ? '' : '暂无登录记录');
|
||||
} catch (error) { text('login-audit-status', `读取失败:${error.message}`); }
|
||||
finally { loginAuditLoading = false; $('login-audit-first').disabled = false; $('login-audit-next').disabled = !loginAuditCursor; }
|
||||
}
|
||||
$('login-audit-open').addEventListener('click', () => { $('login-audit-dialog').showModal(); loadLoginAudit(true); });
|
||||
$('login-audit-close').addEventListener('click', () => $('login-audit-dialog').close());
|
||||
$('login-audit-first').addEventListener('click', () => loadLoginAudit(true));
|
||||
$('login-audit-next').addEventListener('click', () => loadLoginAudit());
|
||||
function formatTime(value) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
|
||||
@@ -39,7 +59,7 @@
|
||||
loadScopeApplications().catch(error => showToast(`应用目录读取失败:${error.message}`));
|
||||
}
|
||||
text('scope-hint', ['workflows', 'retention', 'archives'].includes(next) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示');
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
$('main-content').scrollTo({ top: 0, behavior: 'instant' });
|
||||
if (matchMedia('(max-width:640px)').matches) document.querySelector('.nav-item.active')?.scrollIntoView({inline:'nearest',block:'nearest'});
|
||||
if (next === 'retention' && state.scopeReady) loadRetention();
|
||||
if (next === 'archives' && state.scopeReady) loadArchives();
|
||||
@@ -137,6 +157,7 @@
|
||||
appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application');
|
||||
appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue');
|
||||
$('scope-application').disabled = !scope.can_switch;
|
||||
$('login-audit-open').hidden = !scope.can_switch;
|
||||
$('scope-queue').disabled = !queues.length;
|
||||
if (scope.can_switch) {
|
||||
try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); }
|
||||
@@ -863,7 +884,7 @@
|
||||
if (!state.authenticated || !state.scopeReady) return;
|
||||
if (lists[view]) await loadList(view, background === true);
|
||||
else if (view === 'overview') await loadOverview();
|
||||
else if (view === 'applications') await window.orbitApplications.load();
|
||||
else if (view === 'applications') await window.orbitApplications.load({ background: background === true });
|
||||
else if (view === 'retention') await loadRetention();
|
||||
else if (view === 'archives') await loadArchives();
|
||||
text('last-updated', `更新于 ${formatTime(new Date())}`);
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' };
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '', editNamespace: '' };
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
|
||||
// 与 platform/auth/token.go 的精确动作目录一致;不按能力组自动增加权限。
|
||||
const permissionGroups = [
|
||||
['任务', false, [['tasks:submit', '提交任务'], ['tasks:read', '查询任务'], ['tasks:cancel', '取消任务']]],
|
||||
['队列', false, [['queues:read', '查询队列'], ['queues:manage', '管理队列']]],
|
||||
['Worker 执行', false, [['workers:connect', '建立连接'], ['workers:consume', '领取与执行任务']]],
|
||||
['结果订阅', false, [['events:read', '查询订阅与结果'], ['events:manage', '管理订阅'], ['events:consume', '消费与确认结果']]],
|
||||
['定时调度', false, [['schedules:read', '查询计划'], ['schedules:manage', '管理计划']]],
|
||||
['流程编排', false, [['workflows:definitions:read', '查询定义'], ['workflows:definitions:publish', '发布定义'], ['workflows:start', '启动流程'], ['workflows:read', '查询流程'], ['workflows:control', '控制流程'], ['workflows:signal', '发送信号']]],
|
||||
['高级管理 · 人工恢复与审计', true, [['tasks:review', '核查任务结果'], ['tasks:retry', '人工重试任务'], ['tasks:audit:read', '读取任务操作审计'], ['workflows:recover', '人工恢复流程'], ['workflows:audit:read', '读取流程审计']]],
|
||||
['高级管理 · 应用额度', true, [['application:resources:read', '查询应用额度'], ['application:resources:manage', '调整应用额度']]]
|
||||
];
|
||||
const permissionActions = new Set(permissionGroups.flatMap(([, , actions]) => actions.map(([action]) => action)));
|
||||
function unsupportedPermissions(credential) {
|
||||
const actions = credential.permissions.actions;
|
||||
return actions.some(action => !permissionActions.has(action)) || new Set(actions).size !== actions.length;
|
||||
}
|
||||
function renderPermissionEditor(credential) {
|
||||
const selected = new Set(credential.permissions.actions);
|
||||
$('access-permission-scope').textContent = `命名空间:${ui.editNamespace} · 凭据:${credential.id} · 版本 ${credential.revision}`;
|
||||
$('access-permission-current').textContent = credential.permissions.actions.join(' · ');
|
||||
$('access-permission-queues').textContent = credential.permissions.queues.join(' · ');
|
||||
$('access-permission-lifetime').textContent = `当前 Token 有效期:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 过渡截止:${time(credential.previous_valid_until)}。` : '无旧 Token 过渡。'}`;
|
||||
$('access-permission-warning').hidden = !unsupportedPermissions(credential);
|
||||
$('access-permission-options').innerHTML = permissionGroups.map(([name, advanced, actions]) => `<fieldset class="access-action-group${advanced ? ' access-action-advanced' : ''}"><legend>${escape(name)}</legend>${actions.map(([action, label]) => `<label class="access-action-option"><input type="checkbox" value="${escape(action)}" ${selected.has(action) ? 'checked' : ''}><span>${escape(label)}<code>${escape(action)}</code></span></label>`).join('')}</fieldset>`).join('');
|
||||
}
|
||||
let tokenRequest = 0, tokenTarget = null, selectionSequence = 0;
|
||||
const resourceMessages = {
|
||||
'resource is retired; history remains available':'资源已退役,历史仍可查询。',
|
||||
@@ -38,27 +64,44 @@
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_APPLICATION_ID=${ui.detail?.entry.application_id || ''}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\nORBIT_TOKEN=${token}\n`;
|
||||
}
|
||||
async function load() {
|
||||
let lastScrollAt = -Infinity, detailSignature = '', previousListHTML = '';
|
||||
document.addEventListener('scroll', () => { lastScrollAt = performance.now(); }, true);
|
||||
function editingOrScrolling() {
|
||||
return performance.now() - lastScrollAt < 1500 || document.activeElement?.matches('input,select,textarea') ||
|
||||
!!document.querySelector('dialog[open], #access-detail details[open], #access-retire-form:not([hidden])');
|
||||
}
|
||||
async function load({ background = false } = {}) {
|
||||
if (ui.loading || ui.busy) return;
|
||||
if (background && editingOrScrolling()) return;
|
||||
ui.loading = true; $('access-error').textContent = '';
|
||||
try {
|
||||
const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); ui.next = page.next || ''; $('access-next').disabled = !ui.next;
|
||||
$('access-list').innerHTML = page.items.length ? page.items.map(entry => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||
const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : '');
|
||||
if (background && editingOrScrolling()) return;
|
||||
ui.next = page.next || ''; $('access-next').disabled = !ui.next;
|
||||
const listHTML = page.items.length ? page.items.map(entry => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||
if (previousListHTML !== listHTML) { $('access-list').innerHTML = listHTML; previousListHTML = listHTML; }
|
||||
filterApplications();
|
||||
if (!ui.selected && page.items.length) await select(page.items[0].namespace_id);
|
||||
else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected);
|
||||
else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected, { background });
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
finally { ui.loading = false; }
|
||||
}
|
||||
async function select(namespace) {
|
||||
async function select(namespace, { background = false } = {}) {
|
||||
if (namespace !== ui.selected) {
|
||||
detailSignature = '';
|
||||
retirement = null; $('access-retire-form').hidden = true;
|
||||
$('access-detail').hidden = true; $('access-empty').hidden = false;
|
||||
$('access-empty').textContent = '正在读取应用配置…';
|
||||
}
|
||||
const requested = namespace, ticket = ++selectionSequence; ui.selected = namespace;
|
||||
try {
|
||||
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested || ticket !== selectionSequence) return; ui.detail = detail;
|
||||
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested || ticket !== selectionSequence) return;
|
||||
if (background && editingOrScrolling()) return;
|
||||
const now = Date.now();
|
||||
// 相同快照不重建详情,保留滚动锚点、焦点和折叠状态;到期状态仍按当前时间更新。
|
||||
const signature = JSON.stringify([detail, detail.credentials.map(c => [!!c.expires_at && Date.parse(c.expires_at) <= now, Date.parse(c.previous_valid_until) > now])]);
|
||||
ui.detail = detail;
|
||||
if (signature === detailSignature && !$('access-detail').hidden) return;
|
||||
$('access-empty').hidden = true; $('access-detail').hidden = false;
|
||||
$('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用';
|
||||
$('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`;
|
||||
@@ -71,7 +114,6 @@
|
||||
$('access-copy-template').textContent = availableCredential?.token_stored ? '查看完整接入配置 ↗' : '补存当前 Token ↗';
|
||||
$('access-config-note').textContent = availableCredential?.token_stored ? '选择正确队列后,可查看、复制含当前 Token 的完整配置。' : '当前 Token 尚未补存。请从已保存的新配置导入当前 Token,再查看或复制;过渡期旧 Token 不能用于补存。';
|
||||
$('access-truncated').hidden = !detail.truncated;
|
||||
const now = Date.now();
|
||||
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||
const status = c.revoked_at ? '已撤销' : c.expires_at && Date.parse(c.expires_at) <= now ? '已到期' : '有效';
|
||||
const transitioning = !c.revoked_at && Date.parse(c.previous_valid_until) > now;
|
||||
@@ -86,12 +128,13 @@
|
||||
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
|
||||
${rotationHelp}
|
||||
<details class="credential-controls"><summary>凭据管理与权限</summary><div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||
<details class="credential-controls"><summary>凭据管理与权限</summary><div class="access-form-actions"><button class="text-button" data-access-permissions="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整权限</button><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></details>
|
||||
</section>`;
|
||||
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||
if ($('access-help-dialog').open) renderCredentialHelp();
|
||||
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||
detailSignature = signature;
|
||||
} catch (error) {
|
||||
if (ticket !== selectionSequence) return;
|
||||
$('access-error').textContent = message(error);
|
||||
@@ -151,23 +194,28 @@
|
||||
}
|
||||
function open(credential = null, mode = 'ROTATE') {
|
||||
if (ui.busy) return;
|
||||
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true;
|
||||
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.editNamespace = ui.selected; ui.pending = null; ui.result = null; ui.saved = true;
|
||||
$('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = '';
|
||||
$('access-result-config').value = ''; $('access-clipboard-status').textContent = '';
|
||||
$('access-create-fields').hidden = !!credential;
|
||||
$('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; });
|
||||
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' };
|
||||
$('access-permission-editor').hidden = ui.mode !== 'SET_PERMISSIONS';
|
||||
if (ui.mode === 'SET_PERMISSIONS') renderPermissionEditor(credential);
|
||||
$('access-permission-options').querySelectorAll('input').forEach(el => { el.disabled = ui.mode !== 'SET_PERMISSIONS'; });
|
||||
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡', SET_PERMISSIONS: '调整权限' };
|
||||
$('access-form-title').textContent = titles[ui.mode];
|
||||
$('access-valid-field').hidden = ui.mode === 'FINALIZE';
|
||||
$('access-valid-field').hidden = ['FINALIZE', 'SET_PERMISSIONS'].includes(ui.mode);
|
||||
$('access-grace-field').hidden = ui.mode !== 'ROTATE';
|
||||
$('access-form-note').textContent = {
|
||||
CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。',
|
||||
ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。',
|
||||
SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。',
|
||||
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。'
|
||||
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。',
|
||||
SET_PERMISSIONS: '勾选的动作将完整替换当前权限,取消勾选会收回对应权限。当前和过渡期 Token 均使用调整后的权限;Token、有效期、过渡截止及队列范围保持不变。'
|
||||
}[ui.mode];
|
||||
$('access-token-once-note').hidden = !['CREATE', 'ROTATE'].includes(ui.mode);
|
||||
$('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`;
|
||||
$('access-submit').disabled = ui.mode === 'SET_PERMISSIONS' && unsupportedPermissions(credential);
|
||||
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
|
||||
}
|
||||
function close() {
|
||||
@@ -187,9 +235,16 @@
|
||||
const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]);
|
||||
try {
|
||||
if (!ui.pending) {
|
||||
const input = { kind: ui.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||
const input = { kind: ui.mode, valid_days: ['FINALIZE', 'SET_PERMISSIONS'].includes(ui.mode) ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||
if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value);
|
||||
if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id });
|
||||
if (ui.mode === 'SET_PERMISSIONS') {
|
||||
if (unsupportedPermissions(ui.rotating)) throw new Error('当前凭据包含页面不支持或重复的动作,请更新控制台并核对权限后再操作。');
|
||||
input.actions = [...$('access-permission-options').querySelectorAll('input:checked')].map(el => el.value).sort();
|
||||
if (!input.actions.length) throw new Error('请至少保留一项权限。');
|
||||
if (input.actions.some(action => !permissionActions.has(action)) || new Set(input.actions).size !== input.actions.length) throw new Error('权限选择无效,请重新打开调整权限。');
|
||||
input.grace_days = 0;
|
||||
}
|
||||
if (ui.rotating) Object.assign(input, { namespace_id: ui.editNamespace, credential_id: ui.rotating.id });
|
||||
else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) });
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
ui.pending = await api('/prepare', input);
|
||||
@@ -203,13 +258,14 @@
|
||||
await select(op.namespace_id);
|
||||
const credential = ui.detail?.entry.namespace_id === op.namespace_id && ui.detail.credentials.find(c => c.id === op.credential_id);
|
||||
const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n);
|
||||
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。');
|
||||
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情后核对当前状态。');
|
||||
if (op.input.kind === 'SET_PERMISSIONS' && JSON.stringify([...credential.permissions.actions].sort()) !== JSON.stringify([...op.input.actions].sort())) throw new Error('操作已提交,但当前权限暂时无法核对,请保留原操作重试确认。');
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
const preparedConfig = op.token ? config(op.token, queue, op.namespace_id) : '';
|
||||
ui.result = op; ui.pending = null;
|
||||
$('access-form').hidden = true; $('access-result').hidden = false;
|
||||
$('access-token-once-note').hidden = true;
|
||||
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind];
|
||||
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用', SET_PERMISSIONS: '权限已更新,原 Token 继续使用' }[op.input.kind];
|
||||
$('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token;
|
||||
$('access-result-config').value = preparedConfig;
|
||||
$('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`;
|
||||
@@ -224,12 +280,12 @@
|
||||
$('access-url').value = location.origin;
|
||||
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) { closeToken(); select(el.dataset.accessId); } });
|
||||
$('access-credentials').addEventListener('click', e => {
|
||||
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return;
|
||||
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessToken || el.dataset.accessCopyConfig;
|
||||
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-permissions],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return;
|
||||
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessPermissions || el.dataset.accessToken || el.dataset.accessCopyConfig;
|
||||
const credential = ui.detail?.credentials.find(c => c.id === id);
|
||||
if (credential && (el.dataset.accessToken || el.dataset.accessCopyConfig)) { showToken(credential, !!el.dataset.accessCopyConfig); return; }
|
||||
if (credential && el.dataset.accessHelp) { showCredentialHelp(credential); return; }
|
||||
if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
|
||||
if (credential && !credential.revoked_at) open(credential, el.dataset.accessPermissions ? 'SET_PERMISSIONS' : el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
|
||||
});
|
||||
$('access-help-close').addEventListener('click', () => $('access-help-dialog').close());
|
||||
$('access-help-done').addEventListener('click', () => $('access-help-dialog').close());
|
||||
|
||||
BIN
internal/transport/httpserver/console/favicon.ico
Normal file
BIN
internal/transport/httpserver/console/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
8
internal/transport/httpserver/console/favicon.svg
Normal file
8
internal/transport/httpserver/console/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 35">
|
||||
<rect width="35" height="35" rx="10" fill="#087f75"/>
|
||||
<g fill="#fff">
|
||||
<rect x="8.5" y="12" width="4" height="11" rx="2"/>
|
||||
<rect x="15.5" y="7.5" width="4" height="20" rx="2"/>
|
||||
<rect x="22.5" y="10" width="4" height="15" rx="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 320 B |
@@ -1,13 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" class="console-document">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Orbit Console · 星轨控制台</title>
|
||||
<link rel="icon" href="/console/favicon.ico" sizes="16x16 32x32 48x48">
|
||||
<link rel="icon" type="image/svg+xml" href="/console/favicon.svg" sizes="any">
|
||||
<link rel="stylesheet" href="/console/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="console-body">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
@@ -42,6 +44,7 @@
|
||||
<div class="topbar-actions"><label class="auto-refresh"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
|
||||
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
||||
<span class="last-updated" id="last-updated" aria-live="polite">尚未刷新</span>
|
||||
<button class="secondary-button compact" id="login-audit-open" hidden>登录审计</button>
|
||||
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
|
||||
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
||||
</div>
|
||||
@@ -357,6 +360,14 @@
|
||||
</aside>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status"></div>
|
||||
<dialog id="login-audit-dialog" class="access-dialog login-audit-dialog" aria-labelledby="login-audit-title">
|
||||
<div class="access-dialog-top"><button type="button" class="icon-button" id="login-audit-close" aria-label="关闭登录审计">×</button></div>
|
||||
<h2 id="login-audit-title">登录审计</h2>
|
||||
<p class="panel-description">记录登录成功、失败、临时锁定与限流。每页 10 条,时间为当前时区。</p>
|
||||
<p id="login-audit-status" role="status"></p>
|
||||
<div class="table-wrap"><table><thead><tr><th>时间</th><th>账号</th><th>来源 IP</th><th>结果</th><th>可重试时间</th></tr></thead><tbody id="login-audit-rows"></tbody></table></div>
|
||||
<div class="audit-pagination"><button class="secondary-button compact" id="login-audit-first">最新记录</button><span id="login-audit-page">第 1 页</span><button class="secondary-button compact" id="login-audit-next" disabled>下一页 →</button></div>
|
||||
</dialog>
|
||||
<dialog id="access-dialog" class="access-dialog">
|
||||
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
|
||||
@@ -368,6 +379,14 @@
|
||||
<label>初始队列名称<input id="access-queue-name" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
<fieldset class="access-capabilities"><legend>接入能力 <small>默认包含任务提交、查询与队列管理</small></legend><label><input type="checkbox" value="worker" checked> Worker 执行</label><label><input type="checkbox" value="events" checked> 结果订阅</label><label><input type="checkbox" value="schedules" checked> 定时调度</label><label><input type="checkbox" value="workflows" checked> 流程编排</label><label><input type="checkbox" value="recovery"> 人工恢复</label><label><input type="checkbox" value="resources"> 应用额度管理</label></fieldset>
|
||||
</div>
|
||||
<section id="access-permission-editor" class="access-permission-editor" hidden aria-label="凭据动作权限">
|
||||
<p class="panel-description" id="access-permission-scope"></p>
|
||||
<details class="access-permission-details"><summary>当前精确权限</summary><p id="access-permission-current"></p></details>
|
||||
<div class="access-permission-readonly"><strong>授权队列 · 只读</strong><code id="access-permission-queues"></code><small>星号表示当前命名空间内的全部队列。此次操作保留现有范围。</small><p id="access-permission-lifetime"></p></div>
|
||||
<p class="action-error" id="access-permission-warning" role="alert" hidden>当前凭据包含页面不支持或重复的动作,已禁止提交。请更新控制台并核对权限,避免遗漏现有授权。</p>
|
||||
<p class="panel-description">逐项选择所需权限。高级管理动作需明确勾选,不会随其他能力自动开通。</p>
|
||||
<div id="access-permission-options"></div>
|
||||
</section>
|
||||
<div class="access-config-grid"><label id="access-valid-field">凭据有效期<select id="access-valid-days"><option value="0">长期有效</option><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
|
||||
<label id="access-grace-field" hidden>旧 Token 过渡期<select id="access-grace-days"><option value="7">7 天</option><option value="1">1 天</option><option value="30">30 天</option></select><small>新旧 Token 同时可用。请在截止前完成所有服务的配置更新;旧 Token 原到期时间更早时,以原到期时间为准。</small></label>
|
||||
<p class="action-error" id="access-form-error" role="alert"></p>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>登录 Orbit · 星轨控制台</title>
|
||||
<link rel="icon" href="/console/favicon.ico" sizes="16x16 32x32 48x48">
|
||||
<link rel="icon" type="image/svg+xml" href="/console/favicon.svg" sizes="any">
|
||||
<link rel="stylesheet" href="/console/styles.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
@@ -69,7 +71,31 @@
|
||||
// 初次访问时 URL 片段不会发给服务端,浏览器会把它保留在跳转后的登录地址上。
|
||||
if (!returnTo.includes('#') && /^#\/(overview|tasks|schedules|workflows|subscriptions|retention|archives|applications)$/.test(location.hash)) returnTo += location.hash;
|
||||
fetch('/console/auth/session', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' }).then(response => response.json()).then(body => { if (body.authenticated) location.replace(returnTo); }).catch(() => {});
|
||||
form.addEventListener('submit', async event => { event.preventDefault(); error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…'; try { const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: username.value.trim(), password: password.value }) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body?.error?.message || '登录失败,请检查用户名和密码'); location.replace(returnTo); } catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); } finally { submit.disabled = false; submit.textContent = '登录 Orbit →'; } });
|
||||
let retryUntil = 0, retryTimer;
|
||||
function showRetry() {
|
||||
const remaining = Math.max(0, Math.ceil((retryUntil - Date.now()) / 1000));
|
||||
submit.disabled = remaining > 0;
|
||||
submit.textContent = remaining ? `请等待 ${Math.floor(remaining / 60)}分 ${remaining % 60}秒` : '登录 Orbit →';
|
||||
if (!remaining) clearInterval(retryTimer);
|
||||
}
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (Date.now() < retryUntil) return;
|
||||
error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…';
|
||||
try {
|
||||
const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: username.value.trim(), password: password.value }) });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const seconds = Number(body?.error?.retry_after_seconds);
|
||||
if (response.status === 429 && Number.isFinite(seconds) && seconds > 0) {
|
||||
retryUntil = Date.now() + seconds * 1000;
|
||||
clearInterval(retryTimer); retryTimer = setInterval(showRetry, 1000);
|
||||
}
|
||||
throw new Error(body?.error?.message || '登录失败,请稍后重试');
|
||||
}
|
||||
location.replace(returnTo);
|
||||
} catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); }
|
||||
finally { showRetry(); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -136,6 +136,20 @@ input:focus-visible,textarea:focus-visible { outline:none; }
|
||||
@media(max-width:1200px){.scope-field{flex:1;min-width:160px}.scope-note{margin-left:0}}
|
||||
@media(max-width:920px){.application-scope{gap:12px}.scope-note{flex-basis:100%;margin-top:-4px}.list-filters{grid-template-columns:145px minmax(160px,1fr)}.filter-more{grid-column:1;grid-row:2}.filter-actions{grid-column:2;grid-row:2}.login-brand{padding:40px}.login-intro h1{font-size:40px}}
|
||||
@media(max-width:640px){.application-scope{padding:14px;gap:12px}.scope-field{flex:1 1 100%;min-width:0}.scope-note{display:none}.scope-field>span{margin-bottom:5px}.list-filters{padding:16px;grid-template-columns:1fr 1fr;gap:14px}.list-filters>label{grid-column:1/-1;min-width:0!important}.filter-more{grid-column:1;grid-row:3}.filter-actions{grid-column:2;grid-row:3;gap:7px}.filter-actions button{flex:1;padding:0 11px}.filter-dates{grid-row:4;gap:14px}.filter-dates label{flex:1 1 100%;min-width:0}.filter-dates small{padding:0}.list-pagination{padding:14px 10px;gap:12px}.list-pagination>div{gap:6px}.list-pagination .select-trigger{width:65px;height:32px}.login-brand{padding:28px}.login-brand>*{width:100%;max-width:380px}.login-intro{margin:30px auto 8px}.login-intro h1{font-size:28px}.login-card{width:100%}}
|
||||
/* none 同时关闭边界回弹和滚动传递;contain 仍会让 macOS 上的菜单和内容弹动。
|
||||
根视口与各实际滚动容器分别设置,保留短屏导航、内容和弹层的正常滚动。 */
|
||||
.console-document, .console-body { height:100%; overflow:hidden; overscroll-behavior:none; }
|
||||
.console-body .app-shell { position:fixed; inset:0; height:100%; min-height:0; overflow:hidden; grid-template-rows:minmax(0,1fr); }
|
||||
.console-body .sidebar { position:relative; height:100%; min-height:0; overscroll-behavior:none; }
|
||||
.console-body .main-content { height:100%; min-height:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:none; scrollbar-gutter:stable; scroll-padding-top:100px; }
|
||||
.console-body .topbar { position:sticky; top:0; z-index:20; background:var(--bg); }
|
||||
.console-body :is(.select-options,.table-wrap,.task-drawer,.access-dialog){overscroll-behavior:none}
|
||||
@media(min-width:921px){.access-list-panel{position:sticky;top:100px;max-height:calc(100dvh - 124px);overflow:auto;overscroll-behavior:none}}
|
||||
@media(max-width:640px){.console-body .app-shell{display:flex;flex-direction:column}.console-body .sidebar{height:auto;flex:0 0 auto;overflow:visible}.console-body .main-content{flex:1;scroll-padding-top:78px}.nav-list{overscroll-behavior:none}}
|
||||
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
||||
|
||||
@media(max-width:640px){.data-panel .table-wrap::before{content:"左右滑动查看完整表格 →";display:block;padding:0 15px 10px;color:var(--muted);font-size:10px}.retention-panel{max-width:none}}
|
||||
|
||||
.login-audit-dialog{width:min(960px,calc(100vw - 32px))}.login-audit-dialog .table-wrap{margin:20px 0}.login-audit-dialog th,.login-audit-dialog td{padding:12px;text-align:left;white-space:nowrap}.login-audit-dialog .audit-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
|
||||
.access-permission-editor{margin-top:18px}.access-permission-readonly{border:1px solid var(--line);border-radius:7px;background:var(--soft);padding:12px 14px;margin:14px 0;font-size:11px;color:var(--muted);line-height:1.8}.access-permission-readonly strong,.access-permission-readonly code,.access-permission-readonly small{display:block}.access-permission-readonly code{font-size:11px;color:var(--text);overflow-wrap:anywhere}.access-permission-readonly p{margin-top:8px}.access-action-group{border:1px solid var(--line);border-radius:7px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:14px;margin-top:15px}.access-action-group legend{padding:0 5px;font-size:12px;color:var(--text)}.access-action-option{display:flex;align-items:flex-start;gap:8px;cursor:pointer;color:var(--text);font-size:11px}.access-action-option input{margin:2px 0 0}.access-action-option code{display:block;font-size:10px;color:var(--muted);overflow-wrap:anywhere;line-height:1.7}.access-action-advanced{border-color:#eee2c5;background:#fffcf5}.access-action-advanced legend{color:#826838}@media(max-width:480px){.access-action-group{grid-template-columns:1fr}}
|
||||
|
||||
@@ -44,6 +44,20 @@ func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.St
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
if auditor, ok := store.(consoleauth.LoginAuditor); ok {
|
||||
mux.HandleFunc("GET /console/auth/audit", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := auditor.ListLoginAudit(r.Context(), token, r.URL.Query().Get("before"))
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("GET /console/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -15,7 +15,11 @@ import (
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
type fakeApplications struct{ calls int }
|
||||
type fakeApplications struct {
|
||||
calls int
|
||||
input consoleauth.ApplicationInput
|
||||
op consoleauth.ApplicationOperation
|
||||
}
|
||||
|
||||
func (f *fakeApplications) ListApplications(context.Context, auth.Token, core.ID) (consoleauth.ApplicationPage, error) {
|
||||
f.calls++
|
||||
@@ -25,12 +29,14 @@ func (f *fakeApplications) GetApplication(context.Context, auth.Token, core.ID)
|
||||
f.calls++
|
||||
return consoleauth.ApplicationDetail{}, nil
|
||||
}
|
||||
func (f *fakeApplications) PrepareApplication(context.Context, auth.Token, consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||
func (f *fakeApplications) PrepareApplication(_ context.Context, _ auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||
f.calls++
|
||||
f.input = in
|
||||
return consoleauth.ApplicationOperation{}, nil
|
||||
}
|
||||
func (f *fakeApplications) ApplyApplication(context.Context, auth.Token, consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||
func (f *fakeApplications) ApplyApplication(_ context.Context, _ auth.Token, op consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||
f.calls++
|
||||
f.op = op
|
||||
return consoleauth.ApplicationReceipt{}, nil
|
||||
}
|
||||
func TestConsoleApplicationSessionAndCSRF(t *testing.T) {
|
||||
@@ -91,7 +97,7 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||
queue, _ := core.NewID()
|
||||
opID, _ := core.NewID()
|
||||
token, _, _ := auth.Issue(ns, cred)
|
||||
for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} {
|
||||
for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE", "SET_PERMISSIONS"} {
|
||||
op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: func() *time.Time { v := time.Now().UTC(); return &v }()}
|
||||
if kind == "CREATE" {
|
||||
op.QueueID = queue
|
||||
@@ -104,12 +110,16 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||
op.Input.CredentialID = cred
|
||||
op.Input.ExpectedRevision = 1
|
||||
}
|
||||
if kind == "SET_EXPIRY" || kind == "FINALIZE" {
|
||||
if kind == "SET_EXPIRY" || kind == "FINALIZE" || kind == "SET_PERMISSIONS" {
|
||||
op.Version = 2
|
||||
op.Token = ""
|
||||
op.ExpiresAt = nil
|
||||
op.Input.ValidDays = 0
|
||||
}
|
||||
if kind == "SET_PERMISSIONS" {
|
||||
op.Version = 3
|
||||
op.Input.Actions = []string{"schedules:manage", "schedules:read", "tasks:submit"}
|
||||
}
|
||||
raw, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -125,3 +135,88 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 权限操作仍走管理员 Cookie 与同源保护;新数组字段不能放宽精确 JSON 解码。
|
||||
func TestConsolePermissionChangeHTTPBoundary(t *testing.T) {
|
||||
ns, _ := core.NewID()
|
||||
cred, _ := core.NewID()
|
||||
raw, _, _ := auth.Issue(ns, cred)
|
||||
valid := `{"kind":"SET_PERMISSIONS","namespace_id":"` + string(ns) + `","credential_id":"` + string(cred) + `","expected_revision":"7","reason":"enable scheduling","actions":["tasks:submit","schedules:read","schedules:manage"]}`
|
||||
for _, apply := range []bool{false, true} {
|
||||
path := "/console/applications/prepare"
|
||||
wrap := func(in string) string { return in }
|
||||
if apply {
|
||||
path = "/console/applications/apply"
|
||||
wrap = func(in string) string {
|
||||
return `{"version":3,"input":` + in + `,"operation_id":"` + string(ns) + `","application_id":"` + string(ns) + `","namespace_id":"` + string(ns) + `","credential_id":"` + string(cred) + `"}`
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, body, origin, fetch string
|
||||
cookie, validSession, header bool
|
||||
status int
|
||||
}{
|
||||
{"valid", valid, "http://example.com", "same-origin", true, true, true, 200},
|
||||
{"SDK bearer only", valid, "", "", false, true, true, 401},
|
||||
{"expired session", valid, "", "", true, false, true, 401},
|
||||
{"missing custom header", valid, "", "", true, true, false, 403},
|
||||
{"cross origin", valid, "https://elsewhere.example", "", true, true, true, 403},
|
||||
{"opaque origin", valid, "null", "", true, true, true, 403},
|
||||
{"origin credentials", valid, "http://user@example.com", "", true, true, true, 403},
|
||||
{"cross site metadata", valid, "http://example.com", "cross-site", true, true, true, 403},
|
||||
{"duplicate actions", strings.Replace(valid, `"actions":`, `"actions":[],"actions":`, 1), "", "", true, true, true, 400},
|
||||
{"field alias", strings.Replace(valid, `"actions":`, `"Actions":`, 1), "", "", true, true, true, 400},
|
||||
{"unknown scope field", strings.TrimSuffix(valid, "}") + `,"queues":["*"]}`, "", "", true, true, true, 400},
|
||||
{"null actions", strings.Replace(valid, `["tasks:submit","schedules:read","schedules:manage"]`, `null`, 1), "", "", true, true, true, 400},
|
||||
{"null element", strings.Replace(valid, `"tasks:submit"`, `null`, 1), "", "", true, true, true, 400},
|
||||
{"numeric element", strings.Replace(valid, `"tasks:submit"`, `12`, 1), "", "", true, true, true, 400},
|
||||
{"numeric revision", strings.Replace(valid, `"expected_revision":"7"`, `"expected_revision":7`, 1), "", "", true, true, true, 400},
|
||||
} {
|
||||
t.Run(path+"/"+tc.name, func(t *testing.T) {
|
||||
authStore := &fakeConsoleAuth{valid: tc.validSession}
|
||||
store := &fakeApplications{}
|
||||
server := NewWithConsoleAuth("", nil, authStore)
|
||||
RegisterConsoleApplications(server, authStore, store)
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(wrap(tc.body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if tc.cookie {
|
||||
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
||||
}
|
||||
if tc.header {
|
||||
req.Header.Set("X-Orbit-Console", "1")
|
||||
}
|
||||
if tc.origin != "" {
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
if tc.fetch != "" {
|
||||
req.Header.Set("Sec-Fetch-Site", tc.fetch)
|
||||
}
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(res, req)
|
||||
if res.Code != tc.status {
|
||||
t.Fatalf("status=%d want=%d", res.Code, tc.status)
|
||||
}
|
||||
if res.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("permission response is cacheable")
|
||||
}
|
||||
if tc.status != 200 {
|
||||
if store.calls != 0 {
|
||||
t.Fatal("rejected request reached store")
|
||||
}
|
||||
return
|
||||
}
|
||||
input := store.input
|
||||
if apply {
|
||||
input = store.op.Input
|
||||
if store.op.Version != 3 || store.op.Token != "" {
|
||||
t.Fatal("permission envelope changed")
|
||||
}
|
||||
}
|
||||
if store.calls != 1 || input.Kind != "SET_PERMISSIONS" || input.ExpectedRevision != 7 || len(input.Actions) != 3 || input.Actions[2] != "schedules:manage" {
|
||||
t.Fatal("permission intent changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,10 +19,11 @@ import (
|
||||
type fakeConsoleAuth struct {
|
||||
valid bool
|
||||
login consoleauth.LoginResult
|
||||
loginError error
|
||||
}
|
||||
|
||||
func (f *fakeConsoleAuth) Login(context.Context, string, string, string, string) (consoleauth.LoginResult, error) {
|
||||
return f.login, nil
|
||||
return f.login, f.loginError
|
||||
}
|
||||
func (f *fakeConsoleAuth) ValidateSession(context.Context, auth.Token) error {
|
||||
if !f.valid {
|
||||
@@ -82,3 +86,69 @@ func TestConsoleLoginSetsRootScopedHttpOnlyCookie(t *testing.T) {
|
||||
t.Fatalf("cookie path/httpOnly = %q/%v", cookie.Path, cookie.HttpOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLoginRejectsWithRetryAndNoCookie(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"password", fault.Unauthorized, 401, "UNAUTHENTICATED"},
|
||||
{"locked", &consoleauth.LoginBlocked{Locked: true, RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_LOCKED"},
|
||||
{"rate", &consoleauth.LoginBlocked{RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_RATE_LIMITED"},
|
||||
{"storage", errors.New("private database error"), 503, "UNAVAILABLE"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{loginError: tc.err})
|
||||
req := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"password-password"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, req)
|
||||
if w.Code != tc.status || !strings.Contains(w.Body.String(), tc.code) || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private database") {
|
||||
t.Fatalf("response: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Code == 429 && w.Header().Get("Retry-After") != "2" {
|
||||
t.Fatal("missing rounded Retry-After")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type policyConsoleAuth struct {
|
||||
fakeConsoleAuth
|
||||
policy consoleauth.LoginPolicy
|
||||
}
|
||||
|
||||
func (s *policyConsoleAuth) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
||||
func TestLoginSourceOnlyTrustsConfiguredProxyChain(t *testing.T) {
|
||||
s := &policyConsoleAuth{policy: consoleauth.DefaultLoginPolicy()}
|
||||
s.policy.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
||||
for _, tc := range []struct{ peer, forward, want string }{
|
||||
{"192.0.2.1:1234", "198.51.100.7", "192.0.2.1"},
|
||||
{"10.0.0.1:1234", "198.51.100.7, 10.0.0.2", "198.51.100.7"},
|
||||
{"10.0.0.1:1234", "203.0.113.99, 198.51.100.7", "198.51.100.7"},
|
||||
{"10.0.0.1:1234", "garbage, 10.0.0.2", "10.0.0.1"},
|
||||
{"[::1]:1234", "::ffff:192.0.2.1", "192.0.2.1"},
|
||||
} {
|
||||
r := httptest.NewRequest("POST", "/console/auth/login", nil)
|
||||
r.RemoteAddr = tc.peer
|
||||
r.Header.Set("X-Forwarded-For", tc.forward)
|
||||
r.Header.Set("X-Real-IP", "203.0.113.123")
|
||||
if got := loginRemoteAddress(r, s); got != tc.want {
|
||||
t.Fatalf("%s / %s: got %s", tc.peer, tc.forward, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLoginRejectsCrossSite(t *testing.T) {
|
||||
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{})
|
||||
r := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Origin", "https://evil.invalid")
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, r)
|
||||
if w.Code != 403 || len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("cross-site response: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
57
internal/transport/httpserver/console_login.go
Normal file
57
internal/transport/httpserver/console_login.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// loginRemoteAddress 默认只信任 TCP 对端;仅受信反代可提供 X-Forwarded-For。
|
||||
// 从右往左剥离可信跳点,遇到首个非可信地址即停止;不接受任意客户端伪造的首项。
|
||||
func loginRemoteAddress(r *http.Request, store consoleauth.Store) string {
|
||||
peer, err := netip.ParseAddr(remoteAddress(r))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
peer = peer.Unmap()
|
||||
policyStore, ok := store.(interface {
|
||||
LoginPolicy() consoleauth.LoginPolicy
|
||||
})
|
||||
if !ok {
|
||||
return peer.String()
|
||||
}
|
||||
trusted := policyStore.LoginPolicy().TrustedProxies
|
||||
isTrusted := func(ip netip.Addr) bool {
|
||||
for _, n := range trusted {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !isTrusted(peer) {
|
||||
return peer.String()
|
||||
}
|
||||
raw := strings.Join(r.Header.Values("X-Forwarded-For"), ",")
|
||||
if raw == "" || len(raw) > 2048 {
|
||||
return peer.String()
|
||||
}
|
||||
hops := strings.Split(raw, ",")
|
||||
if len(hops) > 32 {
|
||||
return peer.String()
|
||||
}
|
||||
addresses := make([]netip.Addr, len(hops))
|
||||
for i, h := range hops {
|
||||
a, e := netip.ParseAddr(strings.TrimSpace(h))
|
||||
if e != nil {
|
||||
return peer.String()
|
||||
}
|
||||
addresses[i] = a.Unmap()
|
||||
}
|
||||
current := peer
|
||||
for i := len(addresses) - 1; i >= 0 && isTrusted(current); i-- {
|
||||
current = addresses[i]
|
||||
}
|
||||
return current.String()
|
||||
}
|
||||
@@ -44,14 +44,28 @@ def compose(*args, **kwargs):
|
||||
env.update(ORBIT_LOCAL_DIRECTORY=str(PRIVATE),
|
||||
ORBIT_LOCAL_USER=f'{os.getuid()}:{os.getgid()}')
|
||||
# 本地环境使用 Docker 自带构建器,不占用用户选中的跨平台构建器。
|
||||
env.update(BUILDX_BUILDER=subprocess.check_output(
|
||||
['docker', 'context', 'show'], text=True).strip(), COMPOSE_BAKE='false')
|
||||
command = ['docker', 'compose', '--project-name', 'orbit-test',
|
||||
docker_context = subprocess.check_output(['docker', 'context', 'show'], text=True).strip()
|
||||
env.update(DOCKER_CONTEXT=docker_context, BUILDX_BUILDER=docker_context, COMPOSE_BAKE='false')
|
||||
command = ['docker', '--context', docker_context, 'compose', '--project-name', 'orbit-test',
|
||||
'--env-file', str(ENV_FILE), '-f', str(ROOT / 'deploy/docker-compose.yml'),
|
||||
'-f', str(ROOT / 'deploy/local/compose.yml'), *args]
|
||||
return subprocess.run(command, cwd=ROOT, env=env, check=True, **kwargs)
|
||||
|
||||
|
||||
def build_image(values):
|
||||
# Compose's embedded buildx can lose the selected context for a context-backed
|
||||
# builder. Invoke the Docker CLI explicitly; do not change the user's builder.
|
||||
docker_context = subprocess.check_output(['docker', 'context', 'show'], text=True).strip()
|
||||
command = ['docker', '--context', docker_context, 'buildx', 'build',
|
||||
'--builder', docker_context, '--load', '--tag', values['ORBIT_IMAGE']]
|
||||
for source, target in [('ORBIT_BUILD_VERSION', 'VERSION'),
|
||||
('ORBIT_BUILD_REVISION', 'REVISION'),
|
||||
('ORBIT_BUILD_TIME', 'BUILD_TIME')]:
|
||||
command.extend(['--build-arg', target + '=' + values[source]])
|
||||
command.append('.')
|
||||
subprocess.run(command, cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def wait_ready():
|
||||
for _ in range(60):
|
||||
try:
|
||||
@@ -100,7 +114,7 @@ def setup():
|
||||
values['ORBIT_BUILD_TIME'] = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
save_settings(values)
|
||||
compose('config', '--quiet')
|
||||
compose('build', 'orbit')
|
||||
build_image(values)
|
||||
# 完成构建后再优雅停止旧写节点,避免迁移期间混用凭据存储版本。
|
||||
compose('stop', 'orbit')
|
||||
compose('up', '-d', 'mysql', 'verify', 'archive-init', 'credential-key-init')
|
||||
|
||||
@@ -8,13 +8,14 @@ E2E_IMAGE=${ORBIT_E2E_IMAGE:-orbit:e2e}
|
||||
E2E_SUFFIX=$$
|
||||
E2E_MYSQL="orbit-e2e-mysql-$E2E_SUFFIX"
|
||||
E2E_ORBIT="orbit-e2e-server-$E2E_SUFFIX"
|
||||
E2E_ORBIT_SECOND="orbit-e2e-login-peer-$E2E_SUFFIX"
|
||||
E2E_DIR=$(mktemp -d "${TMPDIR:-/tmp}/orbit-console-e2e.XXXXXX")
|
||||
E2E_PASSWORD_FILE="$E2E_DIR/console-password"
|
||||
E2E_CREDENTIALS_FILE="$E2E_DIR/credentials.json"
|
||||
|
||||
cleanup() {
|
||||
"$E2E_DOCKER" stop "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true
|
||||
"$E2E_DOCKER" container rm "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true
|
||||
"$E2E_DOCKER" stop "$E2E_ORBIT_SECOND" "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true
|
||||
"$E2E_DOCKER" container rm "$E2E_ORBIT_SECOND" "$E2E_ORBIT" "$E2E_MYSQL" >/dev/null 2>&1 || true
|
||||
if [ -d "$E2E_DIR/archive-job" ]; then
|
||||
for e2e_artifact in "$E2E_DIR/archive-job"/.lock "$E2E_DIR/archive-job"/manifest.json "$E2E_DIR/archive-job"/batch-000001.json; do
|
||||
if [ -f "$e2e_artifact" ]; then unlink "$e2e_artifact"; fi
|
||||
@@ -115,6 +116,9 @@ printf '%s\n' "[4/8] start Orbit container"
|
||||
--env ORBIT_MYSQL_DSN="$E2E_DSN" \
|
||||
--env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
|
||||
--env ORBIT_CONSOLE_QUEUE_ID="$E2E_QUEUE" \
|
||||
--env ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT=3 \
|
||||
--env ORBIT_CONSOLE_LOGIN_LOCK_DURATION=10s \
|
||||
--env ORBIT_CONSOLE_LOGIN_RATE_WINDOW=2s \
|
||||
--env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \
|
||||
--env ORBIT_CREDENTIAL_KEY_FILE=/run/credential-keys.json \
|
||||
--volume "$E2E_DIR/credential-keys.json:/run/credential-keys.json:ro" \
|
||||
@@ -199,6 +203,22 @@ assert status == 200, status
|
||||
print("console_login=ok protected_page=ok")
|
||||
PY
|
||||
|
||||
printf '%s\n' "[login] cross-node rejection, restart and recovery"
|
||||
"$E2E_DOCKER" run -d --name "$E2E_ORBIT_SECOND" --network bridge --link "$E2E_MYSQL:mysql" \
|
||||
--env ORBIT_MYSQL_DSN="$E2E_DSN" --env ORBIT_HTTP_ADDR=0.0.0.0:8080 \
|
||||
--env ORBIT_CONSOLE_APPLICATION_ADMIN="$E2E_ADMIN" \
|
||||
--env ORBIT_CREDENTIAL_KEY_FILE=/run/credential-keys.json \
|
||||
--env ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT=3 \
|
||||
--env ORBIT_CONSOLE_LOGIN_LOCK_DURATION=10s \
|
||||
--env ORBIT_CONSOLE_LOGIN_RATE_WINDOW=2s \
|
||||
--volume "$E2E_DIR/credential-keys.json:/run/credential-keys.json:ro" \
|
||||
--publish 127.0.0.1::8080 "$E2E_IMAGE" >/dev/null
|
||||
E2E_SECOND_PORT=$("$E2E_DOCKER" port "$E2E_ORBIT_SECOND" 8080/tcp | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p' | head -n 1)
|
||||
ORBIT_E2E_URL="$E2E_URL" ORBIT_E2E_SECOND_URL="http://127.0.0.1:$E2E_SECOND_PORT" \
|
||||
ORBIT_E2E_SECOND_CONTAINER="$E2E_ORBIT_SECOND" ORBIT_E2E_USER="$E2E_ADMIN" \
|
||||
ORBIT_E2E_PASSWORD_FILE="$E2E_PASSWORD_FILE" DOCKER="$E2E_DOCKER" python3 scripts/test-console-login.py
|
||||
"$E2E_DOCKER" stop "$E2E_ORBIT_SECOND" >/dev/null
|
||||
|
||||
printf '%s\n' "[6/8] WebSocket Worker task execution"
|
||||
(cd examples && ORBIT_URL="$E2E_URL" ORBIT_CREDENTIALS_FILE="$E2E_CREDENTIALS_FILE" \
|
||||
"$E2E_GO" run ./worker) | tee "$E2E_DIR/worker.log"
|
||||
|
||||
68
scripts/test-console-login.py
Normal file
68
scripts/test-console-login.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""由 console-e2e 调用,在一次性双节点环境核验登录保护;不输出密码或 Cookie。"""
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
base = os.environ['ORBIT_E2E_URL']
|
||||
peer = os.environ['ORBIT_E2E_SECOND_URL']
|
||||
username = os.environ['ORBIT_E2E_USER']
|
||||
password = pathlib.Path(os.environ['ORBIT_E2E_PASSWORD_FILE']).read_text().strip()
|
||||
jar = http.cookiejar.CookieJar()
|
||||
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
|
||||
def call(url, path, body=None, headers=None):
|
||||
req = urllib.request.Request(url + path, data=None if body is None else json.dumps(body).encode(), headers={'Content-Type': 'application/json', **(headers or {})})
|
||||
try:
|
||||
with client.open(req, timeout=8) as response:
|
||||
return response.status, json.load(response), response.headers
|
||||
except urllib.error.HTTPError as response:
|
||||
return response.code, json.load(response), response.headers
|
||||
|
||||
|
||||
def ready(url):
|
||||
for _ in range(30):
|
||||
try:
|
||||
if call(url, '/health/ready')[0] == 200:
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
time.sleep(.2)
|
||||
raise AssertionError('test peer not ready')
|
||||
|
||||
|
||||
ready(peer)
|
||||
assert call(base, '/console/auth/login', {'username': username, 'password': password})[0] == 200
|
||||
for url in (base, peer):
|
||||
status, data, headers = call(url, '/console/auth/login', {'username': username, 'password': 'wrong-test-only'})
|
||||
assert status == 401 and not headers.get('Set-Cookie'), status
|
||||
status, data, headers = call(base, '/console/auth/login', {'username': username, 'password': 'wrong-test-only'})
|
||||
assert status == 429 and data['error']['code'] == 'LOGIN_LOCKED' and int(headers['Retry-After']) > 0
|
||||
assert not headers.get('Set-Cookie')
|
||||
assert call(peer, '/console/auth/session')[1]['authenticated'] is True, 'lock killed existing session'
|
||||
status, body, _ = call(peer, '/console/auth/audit')
|
||||
assert status == 200 and any(e['outcome'] == 'LOCKED' for e in body['data']['items'])
|
||||
until = next(e['retry_at'] for e in body['data']['items'] if e['outcome'] == 'LOCKED')
|
||||
subprocess.run([os.environ.get('DOCKER', 'docker'), 'restart', os.environ['ORBIT_E2E_SECOND_CONTAINER']], check=True, stdout=subprocess.DEVNULL)
|
||||
# 随机发布端口在 Docker 重启后可能重新分配,读取实际端口再验证同一节点。
|
||||
published = subprocess.check_output([os.environ.get('DOCKER', 'docker'), 'port', os.environ['ORBIT_E2E_SECOND_CONTAINER'], '8080/tcp'], text=True).strip().splitlines()[0]
|
||||
peer = 'http://127.0.0.1:' + published.rsplit(':', 1)[1]
|
||||
ready(peer)
|
||||
status, data, headers = call(peer, '/console/auth/login', {'username': username, 'password': password}, {'X-Forwarded-For': '203.0.113.88'})
|
||||
assert status == 429 and data['error']['code'] == 'LOGIN_LOCKED', 'restart bypassed lock'
|
||||
body = call(base, '/console/auth/audit')[1]
|
||||
latest = body['data']['items'][0]
|
||||
assert latest['retry_at'] == until and latest['remote_ip'] != '203.0.113.88', 'lock extended or untrusted header accepted'
|
||||
# 仅按真实返回的截止等待;不修改数据库时间或删除锁定记录。
|
||||
time.sleep(int(headers['Retry-After']) + .1)
|
||||
assert call(peer, '/console/auth/login', {'username': username, 'password': password})[0] == 200
|
||||
body = call(base, '/console/auth/audit')[1]
|
||||
assert body['data']['items'][0]['outcome'] == 'SUCCESS'
|
||||
assert password not in json.dumps(body)
|
||||
print('console_login_security=passed cross_node=locked restart=locked existing_session=valid forged_ip=ignored expiry=success audit=confirmed')
|
||||
@@ -55,3 +55,167 @@ test('时间边界验证、流程范围与无队列保护', () => {
|
||||
assert.throws(() => listURL('tasks', ''), /选择队列/);
|
||||
assert.equal(new URL(listURL('workflows', 'ignored'), 'http://localhost').searchParams.has('queue_id'), false);
|
||||
});
|
||||
|
||||
// 用页面原始 HTML 构建轻量 DOM,执行 applications.js 的真实点击与提交处理器。
|
||||
// 不引入浏览器依赖;网络边界独立模拟,断言最终请求及用户可见状态。
|
||||
function applicationsHarness({ actions = ['tasks:read', 'workers:connect'], revoked = false, loseApply = false } = {}) {
|
||||
const decode = text => text.replace(/&(amp|lt|gt|quot|#39);/g, (_, key) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[key]));
|
||||
class Element {
|
||||
constructor(tag = '', attrs = {}) {
|
||||
this.tag = tag; this.attrs = attrs; this.children = []; this.parent = null; this.listeners = {};
|
||||
this.value = attrs.value || ''; this.defaultValue = this.value;
|
||||
this.checked = 'checked' in attrs; this.defaultChecked = this.checked;
|
||||
this.disabled = 'disabled' in attrs; this.hidden = 'hidden' in attrs; this.open = false; this.text = '';
|
||||
this.dataset = Object.fromEntries(Object.entries(attrs).filter(([k]) => k.startsWith('data-')).map(([k, v]) => [k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()), v]));
|
||||
this.classList = { toggle() {} };
|
||||
}
|
||||
matches(selector) {
|
||||
const id = selector.match(/#([\w-]+)/)?.[1], cls = selector.match(/\.([\w-]+)/)?.[1], tag = selector.match(/^[\w-]+/)?.[0];
|
||||
return (!id || this.attrs.id === id) && (!cls || (this.attrs.class || '').split(' ').includes(cls)) && (!tag || this.tag === tag) &&
|
||||
(!selector.includes(':checked') || this.checked) && [...selector.matchAll(/\[([\w-]+)\]/g)].every(([, a]) => a === 'open' ? this.open : a in this.attrs);
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const all = this.children.flatMap(c => [c, ...c.querySelectorAll('*')]);
|
||||
return all.filter(el => selector.split(',').some(s => {
|
||||
const parts = s.trim().split(/\s+/); if (!el.matches(parts.pop())) return false;
|
||||
let ancestor = el.parent;
|
||||
while (parts.length) { const part = parts.pop(); while (ancestor && !ancestor.matches(part)) ancestor = ancestor.parent; if (!ancestor) return false; ancestor = ancestor.parent; }
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
closest(selector) { return selector.split(',').some(s => this.matches(s)) ? this : this.parent?.closest(selector); }
|
||||
set innerHTML(html) { this.html = html; this.children = []; parse(html, this); }
|
||||
get innerHTML() { return this.html || ''; }
|
||||
set textContent(text) { this.text = String(text); this.children = []; }
|
||||
get textContent() { return this.text + this.children.map(c => c.textContent).join(''); }
|
||||
get elements() { return this.querySelectorAll('input,select,button,textarea'); }
|
||||
reset() { this.elements.forEach(el => { el.value = el.defaultValue; el.checked = el.defaultChecked; }); }
|
||||
addEventListener(name, handler) { (this.listeners[name] ||= []).push(handler); }
|
||||
async fire(name, target = this) { for (const handler of this.listeners[name] || []) await handler({ target, preventDefault() {} }); }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
focus() { document.activeElement = this; }
|
||||
}
|
||||
function parse(html, root) {
|
||||
const stack = [root];
|
||||
for (const token of html.matchAll(/<!--[\s\S]*?-->|<[^>]+>|[^<]+/g)) {
|
||||
const raw = token[0]; if (raw.startsWith('<!--') || raw.startsWith('<!')) continue;
|
||||
const tag = raw.match(/^<(\/?)([\w-]+)/);
|
||||
if (!tag) { stack.at(-1).text += decode(raw); continue; }
|
||||
if (tag[1]) { if (stack.at(-1).tag === tag[2]) stack.pop(); continue; }
|
||||
const attrs = Object.fromEntries([...raw.slice(tag[0].length).matchAll(/([\w:-]+)(?:="([^"]*)")?/g)].map(([, k, v]) => [k, decode(v || '')]));
|
||||
const element = new Element(tag[2], attrs); element.parent = stack.at(-1); element.parent.children.push(element);
|
||||
if (element.tag === 'option' && element.parent.tag === 'select' && (element.parent.children.length === 1 || 'selected' in attrs)) element.parent.value = element.parent.defaultValue = element.value;
|
||||
if (!['input', 'meta', 'link', 'br', 'hr', 'img', 'path'].includes(element.tag) && !raw.endsWith('/>')) stack.push(element);
|
||||
}
|
||||
}
|
||||
const document = new Element('document');
|
||||
document.getElementById = id => { const el = document.querySelector(`#${id}`); assert.ok(el, `HTML 缺少 ${id}`); return el; };
|
||||
const base = require('node:path').join(__dirname, '../internal/transport/httpserver/console/');
|
||||
parse(fs.readFileSync(base + 'index.html', 'utf8'), document);
|
||||
const detail = {
|
||||
entry: { application: 'sample-service', environment: 'test', namespace: 'default', namespace_id: 'ns-one', application_id: 'app-one', enabled: true },
|
||||
queues: [{ id: 'queue-one', name: 'primary' }, { id: 'queue-two', name: 'secondary' }],
|
||||
credentials: [{ id: 'cred-one', name: 'main', revision: '7', token_stored: true, expires_at: '2030-01-01T00:00:00Z', previous_valid_until: '2029-01-01T00:00:00Z', ...(revoked ? { revoked_at: '2026-01-01T00:00:00Z' } : {}), permissions: { actions, queues: ['queue-two'] } }]
|
||||
};
|
||||
const requests = [], window = { addEventListener() {}, confirm: () => true };
|
||||
let failed = false;
|
||||
const sandbox = vm.createContext({ document, window, location: { origin: 'http://localhost', replace() {} }, performance: { now: () => 0 }, URL, localStorage: { getItem() { return null; } }, fetch: async (path, options) => {
|
||||
const body = options.body ? JSON.parse(options.body) : undefined;
|
||||
requests.push({ path, body });
|
||||
let data;
|
||||
if (path === '/console/applications') data = { items: [detail.entry] };
|
||||
else if (path === '/console/applications/ns-one') data = detail;
|
||||
else if (path === '/console/applications/prepare') data = { version: body.kind === 'SET_PERMISSIONS' ? 3 : 2, operation_id: 'op-fixed', namespace_id: body.namespace_id, credential_id: body.credential_id, input: body };
|
||||
else if (path === '/console/applications/apply') {
|
||||
detail.credentials[0].revision = '8';
|
||||
if (body.input.actions) detail.credentials[0].permissions.actions = [...body.input.actions];
|
||||
if (loseApply && !failed) { failed = true; throw new Error('响应连接中断'); }
|
||||
data = {};
|
||||
} else throw new Error(`意外请求:${path}`);
|
||||
return { ok: true, status: 200, json: async () => ({ data: JSON.parse(JSON.stringify(data)) }) };
|
||||
} });
|
||||
vm.runInContext(fs.readFileSync(base + 'applications.js', 'utf8'), sandbox);
|
||||
const get = id => document.getElementById(id);
|
||||
return {
|
||||
get, requests, detail,
|
||||
load: () => window.orbitApplications.load(),
|
||||
open: async (action = 'permissions') => { const button = get('access-credentials').querySelector(`[data-access-${action}]`); assert.ok(button, `缺少 ${action} 操作`); await get('access-credentials').fire('click', button); },
|
||||
options: () => get('access-permission-options').querySelectorAll('input'),
|
||||
submit: () => get('access-form').fire('submit')
|
||||
};
|
||||
}
|
||||
|
||||
test('凭据权限弹窗展示完整动作目录,默认保留精确权限和只读队列', async () => {
|
||||
const h = applicationsHarness(); await h.load(); await h.open();
|
||||
assert.equal(h.get('access-dialog').open, true);
|
||||
assert.equal(h.get('access-permission-editor').hidden, false);
|
||||
assert.deepEqual(h.options().filter(el => el.checked).map(el => el.value).sort(), ['tasks:read', 'workers:connect']);
|
||||
const authSource = fs.readFileSync(require('node:path').join(__dirname, '../platform/auth/token.go'), 'utf8');
|
||||
const catalog = [...authSource.match(/var supported = map\[string\]bool\{([^}]+)\}/)[1].matchAll(/"([^"]+)": true/g)].map(([, a]) => a).sort();
|
||||
assert.deepEqual(h.options().map(el => el.value).sort(), catalog, '新增服务端动作必须同步精确选择目录');
|
||||
assert.match(h.get('access-permission-options').textContent, /高级管理 · 人工恢复与审计/);
|
||||
assert.equal(h.get('access-permission-current').textContent, 'tasks:read · workers:connect');
|
||||
assert.equal(h.get('access-permission-queues').textContent, 'queue-two');
|
||||
assert.equal(h.get('access-permission-queues').querySelectorAll('input,select').length, 0);
|
||||
assert.equal(h.get('access-valid-field').hidden, true);
|
||||
assert.equal(h.get('access-grace-field').hidden, true);
|
||||
assert.equal(h.get('access-token-once-note').hidden, true);
|
||||
});
|
||||
|
||||
test('调整权限提交精确全量集合,响应丢失后原操作重放并刷新详情,不读取 Token', async () => {
|
||||
const h = applicationsHarness({ loseApply: true }); await h.load(); await h.open();
|
||||
h.options().forEach(el => { el.checked = ['tasks:read', 'schedules:read', 'schedules:manage'].includes(el.value); });
|
||||
h.get('access-reason').value = '启用计划管理';
|
||||
h.get('access-valid-days').value = '365'; h.get('access-grace-days').value = '30';
|
||||
await h.submit();
|
||||
const prepared = h.requests.find(r => r.path.endsWith('/prepare')).body;
|
||||
assert.deepEqual(prepared, { kind: 'SET_PERMISSIONS', valid_days: 0, grace_days: 0, reason: '启用计划管理', expected_revision: '7', namespace_id: 'ns-one', credential_id: 'cred-one', actions: ['schedules:manage', 'schedules:read', 'tasks:read'] });
|
||||
assert.equal(h.get('access-submit').textContent, '重试确认 →');
|
||||
assert.equal(h.get('access-backup').hidden, false);
|
||||
assert.ok(h.options().every(el => el.disabled));
|
||||
const fixed = h.requests.find(r => r.path.endsWith('/apply')).body;
|
||||
assert.equal(fixed.version, 3); assert.equal(fixed.token, undefined);
|
||||
h.options().forEach(el => { el.checked = true; }); h.get('access-reason').value = '不同的原因';
|
||||
await h.submit();
|
||||
assert.equal(h.requests.filter(r => r.path.endsWith('/prepare')).length, 1);
|
||||
assert.deepEqual(h.requests.filter(r => r.path.endsWith('/apply'))[1].body, fixed);
|
||||
assert.equal(h.get('access-form').hidden, true);
|
||||
assert.equal(h.get('access-result-title').textContent, '权限已更新,原 Token 继续使用');
|
||||
assert.equal(h.get('access-result-config-field').hidden, true);
|
||||
assert.equal(h.get('access-result-actions').hidden, true);
|
||||
assert.equal(h.get('access-result-config').value, '');
|
||||
assert.equal(h.requests.filter(r => r.path === '/console/applications/ns-one').length, 2);
|
||||
assert.equal(h.requests.some(r => r.path.includes('/token/')), false);
|
||||
assert.deepEqual(h.detail.credentials[0].permissions.queues, ['queue-two']);
|
||||
assert.equal(h.detail.credentials[0].expires_at, '2030-01-01T00:00:00Z');
|
||||
assert.equal(h.detail.credentials[0].previous_valid_until, '2029-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
test('空权限、未知旧动作、通配符及重复旧动作不能静默提交', async () => {
|
||||
const empty = applicationsHarness(); await empty.load(); await empty.open();
|
||||
empty.options().forEach(el => { el.checked = false; }); await empty.submit();
|
||||
assert.match(empty.get('access-form-error').textContent, /至少保留一项/);
|
||||
assert.equal(empty.requests.some(r => r.body), false);
|
||||
for (const actions of [['tasks:read', 'future:manage'], ['*'], ['tasks:read', 'tasks:read']]) {
|
||||
const h = applicationsHarness({ actions }); await h.load(); await h.open();
|
||||
assert.equal(h.get('access-permission-warning').hidden, false);
|
||||
assert.equal(h.get('access-submit').disabled, true);
|
||||
assert.equal(h.get('access-permission-current').textContent, actions.join(' · '));
|
||||
await h.submit();
|
||||
assert.equal(h.requests.some(r => r.body), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('撤销凭据禁止打开权限调整,其他操作不携带动作字段', async () => {
|
||||
const revoked = applicationsHarness({ revoked: true }); await revoked.load(); await revoked.open();
|
||||
assert.equal(revoked.get('access-dialog').open, false);
|
||||
const h = applicationsHarness(); await h.load(); await h.open(); await h.open('expiry');
|
||||
assert.equal(h.get('access-permission-editor').hidden, true);
|
||||
assert.ok(h.options().every(el => el.disabled));
|
||||
h.get('access-reason').value = '调整有效期'; await h.submit();
|
||||
const prepared = h.requests.find(r => r.path.endsWith('/prepare')).body;
|
||||
assert.equal(prepared.kind, 'SET_EXPIRY');
|
||||
assert.equal(Object.hasOwn(prepared, 'actions'), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user