feat: add service access tokens
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-30 12:23:06 +08:00
parent 5f5af7b224
commit 13337bcdaf
14 changed files with 974 additions and 37 deletions

View File

@@ -623,6 +623,7 @@ const NAV_ITEMS = [
{ key: 'namespaces', label: '命名空间管理', icon: FolderTree },
{ key: 'environments', label: '环境管理', icon: Globe },
{ key: 'users', label: '用户与权限', icon: Users, adminOnly: true },
{ key: 'tokens', label: '访问令牌', icon: LockKeyhole, adminOnly: true },
{ key: 'audit', label: '审计日志', icon: History, adminOnly: true },
];
function Sidebar({ nav, setNav, identity, onLogout }) {
@@ -667,6 +668,7 @@ const PAGE_META = {
namespaces: { title: '命名空间管理', desc: '管理各应用下的配置命名空间' },
environments: { title: '环境管理', desc: '管理配置生效的环境' },
users: { title: '用户与权限', desc: '管理用户生命周期与应用级 viewer / app-owner 角色' },
tokens: { title: '访问令牌', desc: '为 SDK 和服务实例签发可撤销的应用/环境级访问令牌' },
audit: { title: '审计日志', desc: '按操作者、动作、资源类型和时间范围追溯管理操作' },
};
function TopBar({ nav }) {
@@ -1274,8 +1276,164 @@ function UsersPage({ users, apps, identity, notify, onCreate, onToggleStatus, on
);
}
const AUDIT_ACTIONS = ['', 'create', 'update', 'delete', 'publish', 'rollback', 'enable', 'disable', 'reset_password'];
const AUDIT_TARGETS = ['', 'app', 'env', 'namespace', 'config', 'release', 'gray_rule', 'user', 'user_app_role'];
function CreateServiceTokenModal({ apps, environments, onClose, onCreated }) {
const [form, setForm] = useState({
name: '',
appId: apps[0]?.id || '',
envId: environments[0]?.id || '',
expiresInDays: '365',
read: true,
watch: true,
});
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const set = (key, value) => setForm((current) => ({ ...current, [key]: value }));
async function submit(e) {
e.preventDefault();
setError('');
if (!form.read && !form.watch) {
setError('至少选择一项权限');
return;
}
setSubmitting(true);
try {
await onCreated({
name: form.name,
appId: form.appId,
envId: form.envId,
expiresInDays: Number(form.expiresInDays),
permissions: [form.read && 'config:read', form.watch && 'config:watch'].filter(Boolean),
});
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}>
<form onSubmit={submit} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div><h3 className="text-sm font-semibold">创建访问令牌</h3><p className="text-xs text-slate-400 mt-0.5">用于 SDK / 服务实例读取运行时配置</p></div>
<button type="button" onClick={onClose}><X size={18} /></button>
</div>
<div className="p-5 space-y-4">
<Field label="名称" required><input autoFocus required maxLength={128} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="如 venus-service-dev" className={inputCls()} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="应用" required><select required value={form.appId} onChange={(e) => set('appId', e.target.value)} className={inputCls()}><option value="" disabled>选择应用</option>{apps.map((item) => <option key={item.id} value={item.id}>{item.code} ({item.name})</option>)}</select></Field>
<Field label="环境" required><select required value={form.envId} onChange={(e) => set('envId', e.target.value)} className={inputCls()}><option value="" disabled>选择环境</option>{environments.map((item) => <option key={item.id} value={item.id}>{item.code} ({item.name})</option>)}</select></Field>
</div>
<Field label="权限" required>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-start gap-2 rounded-lg border border-slate-200 p-3 cursor-pointer hover:bg-slate-50"><input type="checkbox" className="mt-0.5" checked={form.read} onChange={(e) => set('read', e.target.checked)} /><span><span className="block text-sm text-slate-700">读取配置</span><span className="block text-[11px] text-slate-400 mt-0.5">GET /v1/config</span></span></label>
<label className="flex items-start gap-2 rounded-lg border border-slate-200 p-3 cursor-pointer hover:bg-slate-50"><input type="checkbox" className="mt-0.5" checked={form.watch} onChange={(e) => set('watch', e.target.checked)} /><span><span className="block text-sm text-slate-700">Watch 配置</span><span className="block text-[11px] text-slate-400 mt-0.5">SSE / gRPC Watch</span></span></label>
</div>
</Field>
<Field label="有效期"><select value={form.expiresInDays} onChange={(e) => set('expiresInDays', e.target.value)} className={inputCls()}><option value="30">30 </option><option value="90">90 </option><option value="365">365 </option><option value="0">永不过期</option></select></Field>
<div className="rounded-lg bg-amber-50 border border-amber-100 px-3 py-2.5 text-xs leading-5 text-amber-700">令牌只能访问所选应用与环境的运行时配置不能修改配置或调用管理 API生产环境建议设置有效期并定期轮换</div>
{error && <p className="text-xs text-rose-600">{error}</p>}
</div>
<div className="px-5 py-3 bg-slate-50 flex justify-end gap-2"><button type="button" onClick={onClose} className="px-3 py-1.5 text-sm text-slate-600">取消</button><button disabled={submitting || !apps.length || !environments.length} className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white disabled:opacity-50">{submitting ? '生成中…' : '生成令牌'}</button></div>
</form>
</div>
);
}
function ServiceTokenSecretModal({ result, onClose, notify }) {
async function copyToken() {
try {
await navigator.clipboard.writeText(result.token);
notify('访问令牌已复制');
} catch {
notify('复制失败,请手动复制令牌', 'danger');
}
}
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-xl overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between"><div><h3 className="text-sm font-semibold flex items-center gap-2"><Check size={16} className="text-emerald-500" />访问令牌已生成</h3><p className="text-xs text-rose-500 mt-1">明文只展示这一次关闭后无法再次查看</p></div><button onClick={onClose}><X size={18} /></button></div>
<div className="p-5 space-y-4">
<div><label className="block text-xs text-slate-500 mb-1.5">Token</label><div className="flex gap-2"><textarea readOnly rows={3} value={result.token} className="flex-1 resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-700 outline-none" /><button onClick={copyToken} className="self-start px-3 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 hover:bg-slate-50">复制</button></div></div>
<div><label className="block text-xs text-slate-500 mb-1.5">Venus 环境变量</label><pre className="rounded-lg bg-slate-900 text-slate-200 p-3 text-xs overflow-x-auto">{`export VENUS_CONFIG_CENTER_TOKEN='${result.token}'`}</pre></div>
<div className="rounded-lg bg-slate-50 border border-slate-200 p-3 text-xs text-slate-500">建议存入 Kubernetes SecretVault 或部署平台 Secret不要写入 Git 仓库镜像或普通配置文件</div>
</div>
<div className="px-5 py-3 bg-slate-50 flex justify-end"><button onClick={onClose} className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white">我已保存关闭</button></div>
</div>
</div>
);
}
function ServiceTokensPage({ apps, environments, notify }) {
const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [created, setCreated] = useState(null);
async function reload() {
setLoading(true);
try { setTokens(await api.listServiceTokens()); }
catch (err) { notify(err.message, 'danger'); }
finally { setLoading(false); }
}
useEffect(() => { reload(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
async function createToken(input) {
const result = await api.createServiceToken(input);
setCreateOpen(false);
setCreated(result);
await reload();
}
async function revokeToken(item) {
if (!window.confirm(`确认撤销访问令牌「${item.name}」?撤销后使用该令牌的 SDK 会立即认证失败。`)) return;
try {
await api.revokeServiceToken(item.id);
notify('访问令牌已撤销');
await reload();
} catch (err) { notify(err.message, 'danger'); }
}
function scope(item) {
const app = apps.find((value) => String(value.id) === String(item.appId));
const env = environments.find((value) => String(value.id) === String(item.envId));
return `${app?.code || `App #${item.appId}`} / ${env?.code || `Env #${item.envId}`}`;
}
function status(item) {
if (item.revokedAt) return { label: '已撤销', cls: 'bg-rose-50 text-rose-600' };
if (item.expiresAtRaw && new Date(item.expiresAtRaw).getTime() <= Date.now()) return { label: '已过期', cls: 'bg-amber-50 text-amber-600' };
return { label: '有效', cls: 'bg-emerald-50 text-emerald-600' };
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-slate-400">用于服务端 SDK 的长期凭证数据库只保存 Token 哈希明文仅在创建时展示一次</p>
<button onClick={() => setCreateOpen(true)} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"><Plus size={15} /> 创建访问令牌</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden overflow-x-auto">
<table className="w-full text-sm min-w-[1100px]">
<thead className="bg-slate-50 text-xs text-slate-500"><tr><th className="text-left font-medium px-4 py-2.5">名称</th><th className="text-left font-medium px-4 py-2.5">Token 前缀</th><th className="text-left font-medium px-4 py-2.5">作用域</th><th className="text-left font-medium px-4 py-2.5">权限</th><th className="text-left font-medium px-4 py-2.5">有效期</th><th className="text-left font-medium px-4 py-2.5">最近使用</th><th className="text-left font-medium px-4 py-2.5">状态</th><th className="text-right font-medium px-4 py-2.5">操作</th></tr></thead>
<tbody className="divide-y divide-slate-100">
{!loading && tokens.map((item) => {
const currentStatus = status(item);
return <tr key={item.id} className="hover:bg-slate-50/60"><td className="px-4 py-3"><div className="font-medium text-slate-700">{item.name}</div><div className="text-[11px] text-slate-400 mt-0.5">创建人{item.createdBy}</div></td><td className="px-4 py-3 font-mono text-xs text-slate-500">{item.tokenPrefix}</td><td className="px-4 py-3 font-mono text-xs text-indigo-600">{scope(item)}</td><td className="px-4 py-3"><div className="flex gap-1 flex-wrap">{item.permissions.map((permission) => <span key={permission} className="px-1.5 py-0.5 rounded bg-slate-100 text-[11px] text-slate-600">{permission}</span>)}</div></td><td className="px-4 py-3 text-xs text-slate-500 whitespace-nowrap">{item.expiresAt || '永不过期'}</td><td className="px-4 py-3 text-xs text-slate-500 whitespace-nowrap">{item.lastUsedAt || '尚未使用'}</td><td className="px-4 py-3"><span className={`px-2 py-0.5 rounded text-xs ${currentStatus.cls}`}>{currentStatus.label}</span></td><td className="px-4 py-3 text-right"><button disabled={Boolean(item.revokedAt)} onClick={() => revokeToken(item)} className="px-2 py-1.5 rounded-md border border-slate-200 text-xs text-slate-600 hover:text-rose-600 hover:bg-rose-50 disabled:opacity-35 disabled:cursor-not-allowed">撤销</button></td></tr>;
})}
{loading && <tr><td colSpan={8} className="px-4 py-10 text-center text-slate-400">正在加载访问令牌</td></tr>}
{!loading && tokens.length === 0 && <tr><td colSpan={8} className="px-4 py-10 text-center text-slate-400">暂无访问令牌</td></tr>}
</tbody>
</table>
</div>
{createOpen && <CreateServiceTokenModal apps={apps} environments={environments} onClose={() => setCreateOpen(false)} onCreated={createToken} />}
{created && <ServiceTokenSecretModal result={created} onClose={() => setCreated(null)} notify={notify} />}
</div>
);
}
const AUDIT_ACTIONS = ['', 'create', 'update', 'delete', 'publish', 'rollback', 'enable', 'disable', 'reset_password', 'revoke'];
const AUDIT_TARGETS = ['', 'app', 'env', 'namespace', 'config', 'release', 'gray_rule', 'user', 'user_app_role', 'service_token'];
function AuditPage({ notify }) {
const emptyFilters = { actor: '', action: '', targetType: '', from: '', to: '' };
@@ -1859,6 +2017,7 @@ export default function ConfigCenter() {
onResetPassword={setPasswordResetUser} onDelete={deleteUser}
/>
)}
{!loading && nav === 'tokens' && identity?.user?.isAdmin && <ServiceTokensPage apps={apps} environments={environments} notify={notify} />}
{!loading && nav === 'audit' && identity?.user?.isAdmin && <AuditPage notify={notify} />}
</main>
</div>

View File

@@ -87,7 +87,7 @@ func (s *Server) GetConfig(ctx context.Context, request *configcenterv1.GetConfi
if err != nil {
return nil, mapError(err)
}
if err := s.authorizer.RequireAppRole(ctx, appID, authpkg.RoleViewer); err != nil {
if err := s.authorizer.RequireRuntimeAccess(ctx, appID, envID, authpkg.PermissionConfigRead); err != nil {
return nil, mapError(err)
}
current, err := s.runtime.Get(ctx, key)
@@ -117,7 +117,7 @@ func (s *Server) WatchConfig(request *configcenterv1.WatchConfigRequest, stream
if err != nil {
return mapError(err)
}
if err := s.authorizer.RequireAppRole(ctx, appID, authpkg.RoleViewer); err != nil {
if err := s.authorizer.RequireRuntimeAccess(ctx, appID, envID, authpkg.PermissionConfigWatch); err != nil {
return mapError(err)
}

View File

@@ -201,6 +201,40 @@ func TestAuthenticationRejectsMissingRoleAndExpiredToken(t *testing.T) {
}
}
func TestServiceTokenScopesGRPCRuntimeOnly(t *testing.T) {
testServer := newTestServer(t, true, 8*time.Hour)
putRuntime(t, testServer.runtime, 1, map[string]string{"server.port": "7788"})
item, token, err := testServer.authorizer.CreateServiceToken(t.Context(), domain.ServiceToken{
Name: "grpc-reader", AppID: 10, EnvironmentID: 1,
Permissions: []string{authpkg.PermissionConfigRead}, CreatedBy: "admin",
})
if err != nil {
t.Fatal(err)
}
response, err := testServer.config.GetConfig(bearerContext(token), &configcenterv1.GetConfigRequest{Env: "DEV", App: "demo-service", Namespace: "application"})
if err != nil || itemValue(response.GetItems(), "server.port") != "7788" {
t.Fatalf("service token gRPC read failed: response=%#v err=%v", response, err)
}
stream, err := testServer.config.WatchConfig(bearerContext(token), &configcenterv1.WatchConfigRequest{Env: "DEV", App: "demo-service", Namespace: "application"})
if err != nil {
t.Fatal(err)
}
if _, err := stream.Recv(); status.Code(err) != codes.PermissionDenied {
t.Fatalf("read-only token must not watch: %v", err)
}
_, err = testServer.admin.PublishConfig(bearerContext(token), &configcenterv1.PublishRequest{EnvId: 1, AppId: 10, NamespaceId: 20})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("service token must not call admin service: %v", err)
}
if err := testServer.repository.RevokeServiceToken(t.Context(), item.ID, "admin"); err != nil {
t.Fatal(err)
}
_, err = testServer.config.GetConfig(bearerContext(token), &configcenterv1.GetConfigRequest{Env: "DEV", App: "demo-service", Namespace: "application"})
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("revoked service token must be unauthenticated: %v", err)
}
}
type grpcTestServer struct {
config configcenterv1.ConfigServiceClient
admin configcenterv1.AdminServiceClient

View File

@@ -74,6 +74,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /v1/users/{id}/roles", s.listUserRoles)
s.mux.HandleFunc("PUT /v1/users/{id}/roles/{appId}", s.setUserRole)
s.mux.HandleFunc("DELETE /v1/users/{id}/roles/{appId}", s.deleteUserRole)
s.mux.HandleFunc("GET /v1/service-tokens", s.listServiceTokens)
s.mux.HandleFunc("POST /v1/service-tokens", s.createServiceToken)
s.mux.HandleFunc("DELETE /v1/service-tokens/{id}", s.revokeServiceToken)
s.mux.HandleFunc("GET /v1/applications", s.listApplications)
s.mux.HandleFunc("POST /v1/applications", s.createApplication)
@@ -300,6 +303,67 @@ func (s *Server) deleteUserRole(w http.ResponseWriter, r *http.Request) {
s.respondEmpty(w, s.store.DeleteUserAppRole(r.Context(), userID, appID, actor(r)))
}
func (s *Server) listServiceTokens(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
items, err := s.store.ListServiceTokens(r.Context())
s.respond(w, items, err, http.StatusOK)
}
func (s *Server) createServiceToken(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var input struct {
Name string `json:"name"`
AppID int64 `json:"appId"`
EnvironmentID int64 `json:"envId"`
Permissions []string `json:"permissions"`
ExpiresInDays int `json:"expiresInDays"`
}
if !decode(w, r, &input) {
return
}
if input.ExpiresInDays < 0 || input.ExpiresInDays > 3650 {
writeError(w, http.StatusBadRequest, "invalid_argument", "令牌有效期必须为 0 到 3650 天0 表示永不过期")
return
}
var expiresAt *time.Time
if input.ExpiresInDays > 0 {
value := time.Now().UTC().AddDate(0, 0, input.ExpiresInDays)
expiresAt = &value
}
item, token, err := s.authorizer.CreateServiceToken(r.Context(), domain.ServiceToken{
Name: input.Name,
AppID: input.AppID,
EnvironmentID: input.EnvironmentID,
Permissions: input.Permissions,
ExpiresAt: expiresAt,
CreatedBy: actor(r),
})
if err != nil {
if strings.Contains(err.Error(), "service token") || strings.Contains(err.Error(), "permission") {
writeError(w, http.StatusBadRequest, "invalid_argument", err.Error())
return
}
s.respond(w, nil, err, http.StatusCreated)
return
}
writeJSON(w, http.StatusCreated, responseEnvelope{Data: map[string]any{"item": item, "token": token}})
}
func (s *Server) revokeServiceToken(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
id, ok := pathID(w, r)
if !ok {
return
}
s.respondEmpty(w, s.store.RevokeServiceToken(r.Context(), id, actor(r)))
}
func (s *Server) listApplications(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListApplications(r.Context())
if err == nil {
@@ -777,7 +841,7 @@ func (s *Server) getRuntimeConfig(w http.ResponseWriter, r *http.Request) {
s.respond(w, nil, err, http.StatusOK)
return
}
if !s.requireApp(w, r, appID, authpkg.RoleViewer) {
if !s.requireRuntime(w, r, appID, envID, authpkg.PermissionConfigRead) {
return
}
item, err := s.runtime.Get(r.Context(), key)
@@ -804,7 +868,7 @@ func (s *Server) watchRuntimeConfig(w http.ResponseWriter, r *http.Request) {
s.respond(w, nil, err, http.StatusOK)
return
}
if !s.requireApp(w, r, appID, authpkg.RoleViewer) {
if !s.requireRuntime(w, r, appID, envID, authpkg.PermissionConfigWatch) {
return
}
flusher, ok := w.(http.Flusher)
@@ -970,6 +1034,18 @@ func (s *Server) requireApp(w http.ResponseWriter, r *http.Request, appID int64,
return true
}
func (s *Server) requireRuntime(w http.ResponseWriter, r *http.Request, appID, envID int64, permission string) bool {
if err := s.authorizer.RequireRuntimeAccess(r.Context(), appID, envID, permission); err != nil {
if errors.Is(err, authpkg.ErrUnauthorized) || errors.Is(err, authpkg.ErrForbidden) {
s.writeAuthError(w, err)
} else {
s.respond(w, nil, err, http.StatusOK)
}
return false
}
return true
}
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions || r.URL.Path == "/v1/auth/login" || strings.HasPrefix(r.URL.Path, "/health/") || r.URL.Path == "/metrics" {
@@ -986,6 +1062,10 @@ func (s *Server) authenticate(next http.Handler) http.Handler {
principal.Username, principal.DisplayName = value, value
}
}
if principal.AuthType == authpkg.AuthTypeServiceToken && r.URL.Path != "/v1/config" && r.URL.Path != "/v1/watch" {
s.writeAuthError(w, authpkg.ErrForbidden)
return
}
next.ServeHTTP(w, r.WithContext(authpkg.WithPrincipal(r.Context(), principal)))
})
}

View File

@@ -296,6 +296,99 @@ func TestUserLifecycleInvalidatesTokensAndAuditPagination(t *testing.T) {
}
}
func TestServiceTokenRuntimeAccessAndRevocation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
repository := memory_store.New(false)
runtimeStore := memoryruntime.New()
defer runtimeStore.Close() //nolint:errcheck
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
authorizer, err := auth.New(repository, true, "0123456789abcdef0123456789abcdef", 90*time.Minute)
if err != nil {
t.Fatal(err)
}
if err := authorizer.Bootstrap(ctx, "root", "a-strong-password", "Root Admin"); err != nil {
t.Fatal(err)
}
collector := metrics.New()
worker := outbox.New(repository, runtimeStore, time.Millisecond, 10, 3, collector, logger)
go worker.Run(ctx)
handler := httpapi.New(repository, runtimeStore, watch.New(ctx, runtimeStore), authorizer, collector, nil, logger).Handler()
var rootLogin struct {
Token string `json:"token"`
}
requestJSON(t, handler, http.MethodPost, "/v1/auth/login", "", map[string]any{"username": "root", "password": "a-strong-password"}, http.StatusOK, &rootLogin)
var app domain.Application
requestJSON(t, handler, http.MethodPost, "/v1/applications", rootLogin.Token, map[string]any{"code": "venus-service", "name": "Venus"}, http.StatusCreated, &app)
var dev domain.Environment
requestJSON(t, handler, http.MethodPost, "/v1/environments", rootLogin.Token, map[string]any{"code": "DEV", "name": "Development"}, http.StatusCreated, &dev)
var testEnv domain.Environment
requestJSON(t, handler, http.MethodPost, "/v1/environments", rootLogin.Token, map[string]any{"code": "TEST", "name": "Test"}, http.StatusCreated, &testEnv)
var namespace domain.Namespace
requestJSON(t, handler, http.MethodPost, "/v1/namespaces", rootLogin.Token, map[string]any{"appId": app.ID, "name": "default", "type": "properties"}, http.StatusCreated, &namespace)
requestJSON(t, handler, http.MethodPost, "/v1/config-items", rootLogin.Token, map[string]any{
"appId": app.ID, "nsId": namespace.ID, "envId": dev.ID, "key": "server.port", "value": "7788",
}, http.StatusCreated, &domain.ConfigItem{})
var release domain.Release
requestJSON(t, handler, http.MethodPost, "/v1/publish", rootLogin.Token, map[string]any{
"appId": app.ID, "nsId": namespace.ID, "envId": dev.ID, "comment": "service token test",
}, http.StatusAccepted, &release)
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
requestJSON(t, handler, http.MethodGet, fmt.Sprintf("/v1/releases/%d", release.ID), rootLogin.Token, nil, http.StatusOK, &release)
if release.Status == "applied" {
break
}
time.Sleep(5 * time.Millisecond)
}
if release.Status != "applied" {
t.Fatalf("release was not applied: %#v", release)
}
var created struct {
Item domain.ServiceToken `json:"item"`
Token string `json:"token"`
}
requestJSON(t, handler, http.MethodPost, "/v1/service-tokens", rootLogin.Token, map[string]any{
"name": "venus-dev", "appId": app.ID, "envId": dev.ID,
"permissions": []string{auth.PermissionConfigRead, auth.PermissionConfigWatch}, "expiresInDays": 30,
}, http.StatusCreated, &created)
if !strings.HasPrefix(created.Token, "cc_pat_") || created.Item.TokenPrefix == "" || !strings.HasPrefix(created.Token, created.Item.TokenPrefix) {
t.Fatalf("unexpected service token response: %#v", created.Item)
}
var current domain.RuntimeConfig
requestJSON(t, handler, http.MethodGet, "/v1/config?env=DEV&app=venus-service&namespace=default", created.Token, nil, http.StatusOK, &current)
if current.Items["server.port"] != "7788" {
t.Fatalf("service token runtime read failed: %#v", current)
}
requestJSON(t, handler, http.MethodGet, "/v1/config?env=TEST&app=venus-service&namespace=default", created.Token, nil, http.StatusForbidden, nil)
requestJSON(t, handler, http.MethodGet, "/v1/applications", created.Token, nil, http.StatusForbidden, nil)
var tokens []domain.ServiceToken
requestJSON(t, handler, http.MethodGet, "/v1/service-tokens", rootLogin.Token, nil, http.StatusOK, &tokens)
if len(tokens) != 1 || tokens[0].ID != created.Item.ID || tokens[0].LastUsedAt == nil {
t.Fatalf("unexpected token list: %#v", tokens)
}
encoded, err := json.Marshal(tokens)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(encoded, []byte(created.Token)) {
t.Fatal("service token secret must never be returned by list API")
}
requestJSON(t, handler, http.MethodDelete, fmt.Sprintf("/v1/service-tokens/%d", created.Item.ID), rootLogin.Token, nil, http.StatusNoContent, nil)
requestJSON(t, handler, http.MethodGet, "/v1/config?env=DEV&app=venus-service&namespace=default", created.Token, nil, http.StatusUnauthorized, nil)
var audits domain.AuditLogPage
requestJSON(t, handler, http.MethodGet, "/v1/audit-logs?targetType=service_token&limit=10", rootLogin.Token, nil, http.StatusOK, &audits)
if len(audits.Items) != 2 || audits.Items[0].Action != "revoke" || audits.Items[1].Action != "create" {
t.Fatalf("service token audit trail is incomplete: %#v", audits.Items)
}
}
func post(t *testing.T, handler http.Handler, path string, input any, wantStatus int, output any) {
t.Helper()
payload, err := json.Marshal(input)

View File

@@ -3,12 +3,15 @@ package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"time"
@@ -27,6 +30,14 @@ const (
RoleViewer = "viewer"
RoleAppOwner = "app-owner"
RoleAdmin = "admin"
AuthTypeUser = "user"
AuthTypeServiceToken = "service-token"
PermissionConfigRead = "config:read"
PermissionConfigWatch = "config:watch"
serviceTokenPrefix = "cc_pat_"
)
type Manager struct {
@@ -80,7 +91,7 @@ func (m *Manager) Bootstrap(ctx context.Context, username, password, displayName
func (m *Manager) Login(ctx context.Context, username, password string) (string, domain.Principal, error) {
if !m.enabled {
principal := domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true}
principal := domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true, AuthType: AuthTypeUser}
token, err := m.issue(principal, 0)
return token, principal, err
}
@@ -88,7 +99,7 @@ func (m *Manager) Login(ctx context.Context, username, password string) (string,
if err != nil || credential.Disabled || bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(password)) != nil {
return "", domain.Principal{}, ErrUnauthorized
}
principal := domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin}
principal := domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin, AuthType: AuthTypeUser}
token, err := m.issue(principal, credential.TokenVersion)
return token, principal, err
}
@@ -116,6 +127,29 @@ func (m *Manager) ResetUserPassword(ctx context.Context, userID int64, password,
return m.store.ResetUserPassword(ctx, userID, string(hash), updatedBy)
}
func (m *Manager) CreateServiceToken(ctx context.Context, input domain.ServiceToken) (domain.ServiceToken, string, error) {
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" || len(input.Name) > 128 || input.AppID <= 0 || input.EnvironmentID <= 0 {
return domain.ServiceToken{}, "", errors.New("service token name, app and environment are required")
}
permissions, err := normalizeServicePermissions(input.Permissions)
if err != nil {
return domain.ServiceToken{}, "", err
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return domain.ServiceToken{}, "", fmt.Errorf("generate service token: %w", err)
}
rawToken := serviceTokenPrefix + base64.RawURLEncoding.EncodeToString(secret)
input.TokenPrefix = rawToken[:min(len(rawToken), len(serviceTokenPrefix)+8)]
input.Permissions = permissions
created, err := m.store.CreateServiceToken(ctx, input, serviceTokenHash(rawToken))
if err != nil {
return domain.ServiceToken{}, "", err
}
return created, rawToken, nil
}
func (m *Manager) AuthenticateRequest(r *http.Request) (domain.Principal, error) {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if !m.enabled {
@@ -129,12 +163,16 @@ func (m *Manager) AuthenticateRequest(r *http.Request) (domain.Principal, error)
func (m *Manager) AuthenticateToken(ctx context.Context, token string) (domain.Principal, error) {
if !m.enabled {
return domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true}, nil
return domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true, AuthType: AuthTypeUser}, nil
}
if strings.TrimSpace(token) == "" {
return domain.Principal{}, ErrUnauthorized
}
return m.parse(ctx, strings.TrimSpace(token))
token = strings.TrimSpace(token)
if strings.HasPrefix(token, serviceTokenPrefix) {
return m.authenticateServiceToken(ctx, token)
}
return m.parse(ctx, token)
}
func WithPrincipal(ctx context.Context, principal domain.Principal) context.Context {
@@ -162,6 +200,9 @@ func (m *Manager) RequireAppRole(ctx context.Context, appID int64, minimum strin
if !ok {
return ErrUnauthorized
}
if principal.AuthType == AuthTypeServiceToken {
return ErrForbidden
}
if principal.IsAdmin {
return nil
}
@@ -182,6 +223,25 @@ func (m *Manager) CanAccessApp(ctx context.Context, appID int64, minimum string)
return m.RequireAppRole(ctx, appID, minimum) == nil
}
func (m *Manager) RequireRuntimeAccess(ctx context.Context, appID, envID int64, permission string) error {
principal, ok := Principal(ctx)
if !ok {
return ErrUnauthorized
}
if principal.AuthType != AuthTypeServiceToken {
return m.RequireAppRole(ctx, appID, RoleViewer)
}
if principal.AppID != appID || principal.EnvironmentID != envID || !hasPermission(principal.Permissions, permission) {
return ErrForbidden
}
return nil
}
func IsServicePrincipal(ctx context.Context) bool {
principal, ok := Principal(ctx)
return ok && principal.AuthType == AuthTypeServiceToken
}
func (m *Manager) issue(principal domain.Principal, version int64) (string, error) {
now := m.now().UTC()
claims := tokenClaims{Issuer: "configcenter", Subject: principal.Username, UserID: principal.UserID, DisplayName: principal.DisplayName, Admin: principal.IsAdmin, Version: version, IssuedAt: now.Unix(), ExpiresAt: now.Add(m.ttl).Unix()}
@@ -195,6 +255,32 @@ func (m *Manager) issue(principal domain.Principal, version int64) (string, erro
return unsigned + "." + encode(signature), nil
}
func (m *Manager) authenticateServiceToken(ctx context.Context, rawToken string) (domain.Principal, error) {
credential, err := m.store.FindServiceTokenByHash(ctx, serviceTokenHash(rawToken))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return domain.Principal{}, ErrUnauthorized
}
return domain.Principal{}, err
}
now := m.now().UTC()
if credential.RevokedAt != nil || (credential.ExpiresAt != nil && !credential.ExpiresAt.After(now)) {
return domain.Principal{}, ErrUnauthorized
}
if err := m.store.TouchServiceToken(ctx, credential.ID); err != nil {
return domain.Principal{}, err
}
return domain.Principal{
Username: "service-token:" + credential.Name,
DisplayName: credential.Name,
AuthType: AuthTypeServiceToken,
ServiceTokenID: credential.ID,
AppID: credential.AppID,
EnvironmentID: credential.EnvironmentID,
Permissions: slices.Clone(credential.Permissions),
}, nil
}
func (m *Manager) parse(ctx context.Context, token string) (domain.Principal, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
@@ -227,7 +313,7 @@ func (m *Manager) parse(ctx context.Context, token string) (domain.Principal, er
if credential.Disabled || credential.TokenVersion != claims.Version || credential.Username != claims.Subject {
return domain.Principal{}, ErrUnauthorized
}
return domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin}, nil
return domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin, AuthType: AuthTypeUser}, nil
}
func (m *Manager) sign(input string) []byte {
@@ -240,6 +326,38 @@ func encode(value []byte) string { return base64.RawURLEncoding.EncodeToString(v
func normalizeUsername(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func serviceTokenHash(rawToken string) string {
sum := sha256.Sum256([]byte(rawToken))
return hex.EncodeToString(sum[:])
}
func normalizeServicePermissions(input []string) ([]string, error) {
seen := make(map[string]struct{}, len(input))
for _, permission := range input {
permission = strings.TrimSpace(permission)
switch permission {
case PermissionConfigRead, PermissionConfigWatch:
seen[permission] = struct{}{}
default:
return nil, fmt.Errorf("invalid service token permission %q", permission)
}
}
result := make([]string, 0, len(seen))
for _, permission := range []string{PermissionConfigRead, PermissionConfigWatch} {
if _, ok := seen[permission]; ok {
result = append(result, permission)
}
}
if len(result) == 0 {
return nil, errors.New("at least one service token permission is required")
}
return result, nil
}
func hasPermission(permissions []string, wanted string) bool {
return slices.Contains(permissions, wanted)
}
func roleRank(role string) int {
switch role {
case RoleViewer:

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -70,3 +71,84 @@ func TestLoginTokenExpiryAndApplicationRoles(t *testing.T) {
t.Fatalf("tampered token must be rejected: %v", err)
}
}
func TestServiceTokenLifecycleAndRuntimeScope(t *testing.T) {
ctx := t.Context()
repository := memory_store.New(false)
manager, err := New(repository, true, "0123456789abcdef0123456789abcdef", time.Hour)
if err != nil {
t.Fatal(err)
}
clock := time.Date(2026, 8, 30, 3, 0, 0, 0, time.UTC)
manager.now = func() time.Time { return clock }
app, err := repository.CreateApplication(ctx, domain.Application{Code: "venus", Name: "Venus"})
if err != nil {
t.Fatal(err)
}
env, err := repository.CreateEnvironment(ctx, domain.Environment{Code: "DEV", Name: "Dev"})
if err != nil {
t.Fatal(err)
}
otherEnv, err := repository.CreateEnvironment(ctx, domain.Environment{Code: "TEST", Name: "Test"})
if err != nil {
t.Fatal(err)
}
expiresAt := clock.Add(30 * 24 * time.Hour)
item, rawToken, err := manager.CreateServiceToken(ctx, domain.ServiceToken{
Name: "venus-dev",
AppID: app.ID,
EnvironmentID: env.ID,
Permissions: []string{PermissionConfigRead},
ExpiresAt: &expiresAt,
CreatedBy: "root",
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(rawToken, "cc_pat_") || rawToken == item.TokenPrefix || !strings.HasPrefix(rawToken, item.TokenPrefix) {
t.Fatalf("unexpected service token presentation: prefix=%q raw-prefix-ok=%v", item.TokenPrefix, strings.HasPrefix(rawToken, "cc_pat_"))
}
principal, err := manager.AuthenticateToken(ctx, rawToken)
if err != nil {
t.Fatal(err)
}
if principal.AuthType != AuthTypeServiceToken || principal.ServiceTokenID != item.ID || principal.AppID != app.ID || principal.EnvironmentID != env.ID {
t.Fatalf("unexpected service principal: %#v", principal)
}
serviceCtx := WithPrincipal(ctx, principal)
if err := manager.RequireRuntimeAccess(serviceCtx, app.ID, env.ID, PermissionConfigRead); err != nil {
t.Fatalf("read permission must be allowed: %v", err)
}
if err := manager.RequireRuntimeAccess(serviceCtx, app.ID, env.ID, PermissionConfigWatch); !errors.Is(err, ErrForbidden) {
t.Fatalf("watch permission must be denied: %v", err)
}
if err := manager.RequireRuntimeAccess(serviceCtx, app.ID, otherEnv.ID, PermissionConfigRead); !errors.Is(err, ErrForbidden) {
t.Fatalf("cross-environment access must be denied: %v", err)
}
if err := manager.RequireAppRole(serviceCtx, app.ID, RoleViewer); !errors.Is(err, ErrForbidden) {
t.Fatalf("service token must not inherit management viewer access: %v", err)
}
listed, err := repository.ListServiceTokens(ctx)
if err != nil || len(listed) != 1 || listed[0].LastUsedAt == nil {
t.Fatalf("service token last-used tracking failed: items=%#v err=%v", listed, err)
}
if err := repository.RevokeServiceToken(ctx, item.ID, "root"); err != nil {
t.Fatal(err)
}
if _, err := manager.AuthenticateToken(ctx, rawToken); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("revoked token must be rejected: %v", err)
}
shortExpiry := clock.Add(time.Minute)
_, expiringToken, err := manager.CreateServiceToken(ctx, domain.ServiceToken{
Name: "short-lived", AppID: app.ID, EnvironmentID: env.ID,
Permissions: []string{PermissionConfigRead, PermissionConfigWatch}, ExpiresAt: &shortExpiry, CreatedBy: "root",
})
if err != nil {
t.Fatal(err)
}
clock = clock.Add(2 * time.Minute)
if _, err := manager.AuthenticateToken(ctx, expiringToken); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expired service token must be rejected: %v", err)
}
}

View File

@@ -159,10 +159,34 @@ type UserAppRole struct {
}
type Principal struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
UserID int64 `json:"userId"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
AuthType string `json:"authType,omitempty"`
ServiceTokenID int64 `json:"serviceTokenId,omitempty"`
AppID int64 `json:"appId,omitempty"`
EnvironmentID int64 `json:"envId,omitempty"`
Permissions []string `json:"permissions,omitempty"`
}
type ServiceToken struct {
ID int64 `json:"id"`
Name string `json:"name"`
TokenPrefix string `json:"tokenPrefix"`
AppID int64 `json:"appId"`
EnvironmentID int64 `json:"envId"`
Permissions []string `json:"permissions"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
}
type ServiceTokenCredential struct {
ServiceToken
TokenHash string `json:"-"`
}
type GrayRule struct {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
@@ -15,18 +16,19 @@ import (
)
type Store struct {
mu sync.RWMutex
nextID int64
applications map[int64]domain.Application
environments map[int64]domain.Environment
namespaces map[int64]domain.Namespace
configs map[int64]domain.ConfigItem
releases map[int64]domain.Release
outbox map[int64]outboxRecord
audits []domain.AuditLog
users map[int64]domain.UserCredential
userRoles map[[2]int64]string
grayRules map[int64]domain.GrayRule
mu sync.RWMutex
nextID int64
applications map[int64]domain.Application
environments map[int64]domain.Environment
namespaces map[int64]domain.Namespace
configs map[int64]domain.ConfigItem
releases map[int64]domain.Release
outbox map[int64]outboxRecord
audits []domain.AuditLog
users map[int64]domain.UserCredential
userRoles map[[2]int64]string
serviceTokens map[int64]domain.ServiceTokenCredential
grayRules map[int64]domain.GrayRule
}
type outboxRecord struct {
@@ -37,16 +39,17 @@ type outboxRecord struct {
func New(withSeed bool) *Store {
s := &Store{
nextID: 100,
applications: make(map[int64]domain.Application),
environments: make(map[int64]domain.Environment),
namespaces: make(map[int64]domain.Namespace),
configs: make(map[int64]domain.ConfigItem),
releases: make(map[int64]domain.Release),
outbox: make(map[int64]outboxRecord),
users: make(map[int64]domain.UserCredential),
userRoles: make(map[[2]int64]string),
grayRules: make(map[int64]domain.GrayRule),
nextID: 100,
applications: make(map[int64]domain.Application),
environments: make(map[int64]domain.Environment),
namespaces: make(map[int64]domain.Namespace),
configs: make(map[int64]domain.ConfigItem),
releases: make(map[int64]domain.Release),
outbox: make(map[int64]outboxRecord),
users: make(map[int64]domain.UserCredential),
userRoles: make(map[[2]int64]string),
serviceTokens: make(map[int64]domain.ServiceTokenCredential),
grayRules: make(map[int64]domain.GrayRule),
}
if withSeed {
s.seed()
@@ -158,6 +161,11 @@ func (s *Store) DeleteApplication(_ context.Context, id int64, actor string) err
delete(s.userRoles, key)
}
}
for tokenID, token := range s.serviceTokens {
if token.AppID == id {
delete(s.serviceTokens, tokenID)
}
}
s.audit(defaultActor(actor), "delete", "app", id, nil)
return nil
}
@@ -218,6 +226,11 @@ func (s *Store) DeleteEnvironment(_ context.Context, id int64, actor string) err
delete(s.configs, configID)
}
}
for tokenID, token := range s.serviceTokens {
if token.EnvironmentID == id {
delete(s.serviceTokens, tokenID)
}
}
for releaseID, item := range s.releases {
if item.EnvironmentID == id {
delete(s.releases, releaseID)
@@ -729,6 +742,88 @@ func (s *Store) ListUserAppRoles(_ context.Context, userID int64) ([]domain.User
return result, nil
}
func (s *Store) ListServiceTokens(context.Context) ([]domain.ServiceToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]domain.ServiceToken, 0, len(s.serviceTokens))
for _, credential := range s.serviceTokens {
result = append(result, cloneServiceToken(credential.ServiceToken))
}
sort.Slice(result, func(i, j int) bool {
if result[i].CreatedAt.Equal(result[j].CreatedAt) {
return result[i].ID > result[j].ID
}
return result[i].CreatedAt.After(result[j].CreatedAt)
})
return result, nil
}
func (s *Store) CreateServiceToken(_ context.Context, input domain.ServiceToken, tokenHash string) (domain.ServiceToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.applications[input.AppID]; !ok {
return domain.ServiceToken{}, storepkg.ErrNotFound
}
if _, ok := s.environments[input.EnvironmentID]; !ok {
return domain.ServiceToken{}, storepkg.ErrNotFound
}
for _, credential := range s.serviceTokens {
if credential.TokenHash == tokenHash {
return domain.ServiceToken{}, storepkg.ErrConflict
}
}
input.ID = s.id()
input.CreatedAt = time.Now().UTC()
input.Permissions = slices.Clone(input.Permissions)
s.serviceTokens[input.ID] = domain.ServiceTokenCredential{ServiceToken: cloneServiceToken(input), TokenHash: tokenHash}
s.audit(defaultActor(input.CreatedBy), "create", "service_token", input.ID, map[string]any{
"name": input.Name, "appId": input.AppID, "envId": input.EnvironmentID, "permissions": input.Permissions, "expiresAt": input.ExpiresAt,
})
return cloneServiceToken(input), nil
}
func (s *Store) FindServiceTokenByHash(_ context.Context, tokenHash string) (domain.ServiceTokenCredential, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, credential := range s.serviceTokens {
if credential.TokenHash == tokenHash {
credential.ServiceToken = cloneServiceToken(credential.ServiceToken)
return credential, nil
}
}
return domain.ServiceTokenCredential{}, storepkg.ErrNotFound
}
func (s *Store) TouchServiceToken(_ context.Context, id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
credential, ok := s.serviceTokens[id]
if !ok {
return storepkg.ErrNotFound
}
now := time.Now().UTC()
credential.LastUsedAt = &now
s.serviceTokens[id] = credential
return nil
}
func (s *Store) RevokeServiceToken(_ context.Context, id int64, updatedBy string) error {
s.mu.Lock()
defer s.mu.Unlock()
credential, ok := s.serviceTokens[id]
if !ok {
return storepkg.ErrNotFound
}
if credential.RevokedAt != nil {
return nil
}
now := time.Now().UTC()
credential.RevokedAt = &now
s.serviceTokens[id] = credential
s.audit(defaultActor(updatedBy), "revoke", "service_token", id, map[string]any{"name": credential.Name})
return nil
}
func (s *Store) activeAdminCountLocked() int {
count := 0
for _, credential := range s.users {
@@ -739,6 +834,23 @@ func (s *Store) activeAdminCountLocked() int {
return count
}
func cloneServiceToken(input domain.ServiceToken) domain.ServiceToken {
input.Permissions = slices.Clone(input.Permissions)
if input.ExpiresAt != nil {
value := *input.ExpiresAt
input.ExpiresAt = &value
}
if input.RevokedAt != nil {
value := *input.RevokedAt
input.RevokedAt = &value
}
if input.LastUsedAt != nil {
value := *input.LastUsedAt
input.LastUsedAt = &value
}
return input
}
func (s *Store) ResolveScope(_ context.Context, envCode, appCode, namespaceName string) (int64, int64, int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS service_tokens (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
token_hash VARCHAR(64) UNIQUE NOT NULL,
token_prefix VARCHAR(32) NOT NULL,
app_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
env_id BIGINT NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
permissions TEXT[] NOT NULL,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(64) NOT NULL,
CONSTRAINT service_tokens_permissions_check CHECK (
cardinality(permissions) > 0
AND permissions <@ ARRAY['config:read','config:watch']::TEXT[]
)
);
CREATE INDEX IF NOT EXISTS service_tokens_scope_idx ON service_tokens(app_id, env_id, revoked_at);
CREATE INDEX IF NOT EXISTS service_tokens_created_idx ON service_tokens(created_at DESC);

View File

@@ -813,6 +813,95 @@ func (s *Store) ListUserAppRoles(ctx context.Context, userID int64) ([]domain.Us
return result, nil
}
func (s *Store) ListServiceTokens(ctx context.Context) ([]domain.ServiceToken, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, name, token_prefix, app_id, env_id, permissions, expires_at, revoked_at, last_used_at, created_at, created_by
FROM service_tokens ORDER BY created_at DESC, id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]domain.ServiceToken, 0)
for rows.Next() {
var item domain.ServiceToken
if err := rows.Scan(&item.ID, &item.Name, &item.TokenPrefix, &item.AppID, &item.EnvironmentID, &item.Permissions, &item.ExpiresAt, &item.RevokedAt, &item.LastUsedAt, &item.CreatedAt, &item.CreatedBy); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) CreateServiceToken(ctx context.Context, input domain.ServiceToken, tokenHash string) (domain.ServiceToken, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.ServiceToken{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck
err = tx.QueryRow(ctx, `
INSERT INTO service_tokens(name, token_hash, token_prefix, app_id, env_id, permissions, expires_at, created_by)
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, name, token_prefix, app_id, env_id, permissions, expires_at, revoked_at, last_used_at, created_at, created_by`,
input.Name, tokenHash, input.TokenPrefix, input.AppID, input.EnvironmentID, input.Permissions, input.ExpiresAt, input.CreatedBy,
).Scan(&input.ID, &input.Name, &input.TokenPrefix, &input.AppID, &input.EnvironmentID, &input.Permissions, &input.ExpiresAt, &input.RevokedAt, &input.LastUsedAt, &input.CreatedAt, &input.CreatedBy)
if err != nil {
return domain.ServiceToken{}, classify(err)
}
if err := insertAudit(ctx, tx, actor(input.CreatedBy), "create", "service_token", input.ID, map[string]any{
"name": input.Name, "appId": input.AppID, "envId": input.EnvironmentID, "permissions": input.Permissions, "expiresAt": input.ExpiresAt,
}); err != nil {
return domain.ServiceToken{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.ServiceToken{}, err
}
return input, nil
}
func (s *Store) FindServiceTokenByHash(ctx context.Context, tokenHash string) (domain.ServiceTokenCredential, error) {
var item domain.ServiceTokenCredential
err := s.pool.QueryRow(ctx, `
SELECT id, name, token_hash, token_prefix, app_id, env_id, permissions, expires_at, revoked_at, last_used_at, created_at, created_by
FROM service_tokens WHERE token_hash=$1`, tokenHash).
Scan(&item.ID, &item.Name, &item.TokenHash, &item.TokenPrefix, &item.AppID, &item.EnvironmentID, &item.Permissions, &item.ExpiresAt, &item.RevokedAt, &item.LastUsedAt, &item.CreatedAt, &item.CreatedBy)
return item, classify(err)
}
func (s *Store) TouchServiceToken(ctx context.Context, id int64) error {
command, err := s.pool.Exec(ctx, `UPDATE service_tokens SET last_used_at=now() WHERE id=$1`, id)
if err != nil {
return err
}
if command.RowsAffected() == 0 {
return storepkg.ErrNotFound
}
return nil
}
func (s *Store) RevokeServiceToken(ctx context.Context, id int64, updatedBy string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
var name string
var revokedAt *time.Time
err = tx.QueryRow(ctx, `SELECT name, revoked_at FROM service_tokens WHERE id=$1 FOR UPDATE`, id).Scan(&name, &revokedAt)
if err != nil {
return classify(err)
}
if revokedAt != nil {
return nil
}
if _, err := tx.Exec(ctx, `UPDATE service_tokens SET revoked_at=now() WHERE id=$1`, id); err != nil {
return err
}
if err := insertAudit(ctx, tx, actor(updatedBy), "revoke", "service_token", id, map[string]any{"name": name}); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) ResolveScope(ctx context.Context, envCode, appCode, namespaceName string) (int64, int64, int64, error) {
var appID, namespaceID, envID int64
err := s.pool.QueryRow(ctx, `

View File

@@ -2,7 +2,9 @@ package postgres
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
"strconv"
"testing"
@@ -120,3 +122,98 @@ func TestUserLifecycleAndAuditIntegration(t *testing.T) {
t.Fatalf("audit cursor did not advance: first=%#v next=%#v", page.Items, next.Items)
}
}
func TestServiceTokenIntegration(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not configured")
}
ctx := t.Context()
repository, err := Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer repository.Close()
if err := repository.Migrate(ctx); err != nil {
t.Fatal(err)
}
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
appCode := "token-" + suffix
envCode := "T" + suffix
if len(envCode) > 32 {
envCode = envCode[:32]
}
app, err := repository.CreateApplication(ctx, domain.Application{Code: appCode, Name: "Token Test", UpdatedBy: "root"})
if err != nil {
t.Fatal(err)
}
env, err := repository.CreateEnvironment(ctx, domain.Environment{Code: envCode, Name: "Token Test", UpdatedBy: "root"})
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = repository.pool.Exec(context.Background(), `DELETE FROM applications WHERE id=$1`, app.ID)
_, _ = repository.pool.Exec(context.Background(), `DELETE FROM environments WHERE id=$1`, env.ID)
}()
sum := sha256.Sum256([]byte(suffix))
tokenHash := fmt.Sprintf("%x", sum[:])
expiresAt := time.Now().UTC().Add(24 * time.Hour)
created, err := repository.CreateServiceToken(ctx, domain.ServiceToken{
Name: "venus-dev", TokenPrefix: "cc_pat_test1234", AppID: app.ID, EnvironmentID: env.ID,
Permissions: []string{"config:read", "config:watch"}, ExpiresAt: &expiresAt, CreatedBy: "root",
}, tokenHash)
if err != nil {
t.Fatal(err)
}
credential, err := repository.FindServiceTokenByHash(ctx, tokenHash)
if err != nil {
t.Fatal(err)
}
if credential.ID != created.ID || credential.TokenHash != tokenHash || credential.AppID != app.ID || credential.EnvironmentID != env.ID || len(credential.Permissions) != 2 {
t.Fatalf("unexpected stored credential: %#v", credential)
}
if err := repository.TouchServiceToken(ctx, created.ID); err != nil {
t.Fatal(err)
}
items, err := repository.ListServiceTokens(ctx)
if err != nil {
t.Fatal(err)
}
var found *domain.ServiceToken
for i := range items {
if items[i].ID == created.ID {
found = &items[i]
break
}
}
if found == nil || found.LastUsedAt == nil {
t.Fatalf("last-used timestamp not persisted: %#v", found)
}
if err := repository.RevokeServiceToken(ctx, created.ID, "root"); err != nil {
t.Fatal(err)
}
credential, err = repository.FindServiceTokenByHash(ctx, tokenHash)
if err != nil {
t.Fatal(err)
}
if credential.RevokedAt == nil {
t.Fatal("revocation timestamp was not persisted")
}
page, err := repository.ListAuditLogs(ctx, domain.AuditLogQuery{Actor: "root", TargetType: "service_token", Limit: 10})
if err != nil {
t.Fatal(err)
}
var createFound, revokeFound bool
for _, item := range page.Items {
if item.TargetID == nil || *item.TargetID != created.ID {
continue
}
createFound = createFound || item.Action == "create"
revokeFound = revokeFound || item.Action == "revoke"
}
if !createFound || !revokeFound {
t.Fatalf("service token audit records missing: %#v", page.Items)
}
}

View File

@@ -60,6 +60,12 @@ type Store interface {
GetUserAppRole(context.Context, int64, int64) (string, error)
ListUserAppRoles(context.Context, int64) ([]domain.UserAppRole, error)
ListServiceTokens(context.Context) ([]domain.ServiceToken, error)
CreateServiceToken(context.Context, domain.ServiceToken, string) (domain.ServiceToken, error)
FindServiceTokenByHash(context.Context, string) (domain.ServiceTokenCredential, error)
TouchServiceToken(context.Context, int64) error
RevokeServiceToken(context.Context, int64, string) error
ResolveScope(context.Context, string, string, string) (int64, int64, int64, error)
ListGrayRules(context.Context, int64, int64, int64) ([]domain.GrayRule, error)
GetGrayRule(context.Context, int64) (domain.GrayRule, error)

View File

@@ -90,6 +90,20 @@ function auditLog(item) {
};
}
function serviceToken(item) {
return {
...item,
id: normalizeId(item.id),
appId: normalizeId(item.appId),
envId: normalizeId(item.envId),
expiresAtRaw: item.expiresAt || '',
expiresAt: localTime(item.expiresAt),
revokedAt: localTime(item.revokedAt),
lastUsedAt: localTime(item.lastUsedAt),
createdAt: localTime(item.createdAt),
};
}
async function request(path, options = {}) {
const token = accessToken();
const response = await fetch(`${API_ROOT}${path}`, {
@@ -214,6 +228,14 @@ export const api = {
listUserRoles: (id) => request(`/v1/users/${id}/roles`),
setUserRole: (userId, appId, role) => request(`/v1/users/${userId}/roles/${appId}`, json('PUT', { role })),
deleteUserRole: (userId, appId) => request(`/v1/users/${userId}/roles/${appId}`, { method: 'DELETE' }),
listServiceTokens: () => request('/v1/service-tokens').then((items) => items.map(serviceToken)),
createServiceToken: (input) => request('/v1/service-tokens', json('POST', {
...input,
appId: Number(input.appId),
envId: Number(input.envId),
expiresInDays: Number(input.expiresInDays),
})).then((result) => ({ ...result, item: serviceToken(result.item) })),
revokeServiceToken: (id) => request(`/v1/service-tokens/${id}`, { method: 'DELETE' }),
async listAuditLogs(filters = {}) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {