feat: add YAML-first users nav access (#2188)

This commit is contained in:
Lyon
2026-06-26 12:23:37 +08:00
committed by GitHub
parent 11afa1a7d1
commit 38b7d7bc87
12 changed files with 392 additions and 46 deletions
@@ -0,0 +1,16 @@
apiVersion: hwlab.dev/v0alpha1
kind: HwlabNavProfiles
metadata:
name: d601-v03-nav-profiles
verificationIssue: pikasTech/HWLAB#2187
spec:
profiles:
- id: admin-full
displayName: Admin full access
allowedIds:
- "*"
- id: code-mdtodo-only
displayName: Code and MDTODO only
allowedIds:
- workbench.code
- project.mdtodo
@@ -0,0 +1,26 @@
apiVersion: hwlab.dev/v0alpha1
kind: HwlabAccessControlUsers
metadata:
name: d601-v03-users
verificationIssue: pikasTech/HWLAB#2187
spec:
users:
- id: usr_v03_admin
username: admin
displayName: HWLAB v0.3 Admin
role: admin
status: active
navProfile: admin-full
passwordHashRef:
env: HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH
secretRef: hwlab-v03-bootstrap-admin/password-hash
- id: usr_v03_huiggao
username: huiggao@163.com
displayName: huiggao@163.com
email: huiggao@163.com
role: user
status: active
navProfile: code-mdtodo-only
passwordHashRef:
env: HWLAB_PRESET_USER_HUIGGAO_PASSWORD_HASH
secretRef: hwlab-v03-preset-users/huiggao-password-hash
+3
View File
@@ -325,6 +325,9 @@ lanes:
artifactCatalog: deploy/artifact-catalog.v03.json
runtimePath: runtime-v03
imageTagMode: full
accessControl:
usersRef: config/hwlab-access-control/users.d601-v03.yaml#spec.users
navProfilesRef: config/hwlab-access-control/nav-profiles.yaml#spec.profiles
workbench:
traceTimeline:
autoExpandRunning: false
+137 -17
View File
@@ -37,6 +37,9 @@ const AUTH_METHOD_WEB_SESSION = "web-session";
const AUTH_METHOD_LEGACY_LOCAL_SESSION = "legacy-local-session";
const AUTH_METHOD_USER_BILLING_API_KEY = "user-billing-api-key";
const AUTH_METHOD_USER_BILLING_SESSION = "user-billing-session";
const ACCESS_NAV_ALL = "*";
const ACCESS_DEFAULT_ADMIN_PROFILE = "admin-full";
const ACCESS_DEFAULT_AUTHENTICATED_PROFILE = "authenticated-full";
const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
@@ -238,6 +241,7 @@ class AccessController {
actor: publicActor(auth.actor),
session: auth.session,
authMethod: publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)),
access: auth.access ?? this.accessClaimsForActor(auth.actor),
contractVersion: "user-access-v1"
});
}
@@ -568,6 +572,30 @@ class AccessController {
for (const user of users.filter((item) => item.role === "admin")) await this.syncUserAdminTuple(user, user);
}
async syncPresetUsers() {
const users = presetAccessUsersFromEnv(this.env);
for (const spec of users) {
const existingById = await this.store.getUserById?.(spec.id) ?? null;
const existing = existingById ?? await this.store.findUserByUsername?.(spec.username) ?? null;
const passwordHash = passwordHashForPresetUser(spec, this.env) || existing?.passwordHash || null;
if (!passwordHash && !existing) continue;
const user = await this.store.createUser({
id: spec.id,
username: spec.username,
displayName: spec.displayName || spec.username,
role: spec.role === "admin" ? "admin" : "user",
status: spec.status === "disabled" ? "disabled" : "active",
passwordHash,
email: spec.email || null,
now: this.now()
});
await this.syncUserAdminTuple(user, user);
if (user?.role === "admin") {
for (const toolId of HWLAB_TOOL_IDS) await this.grantAccessTuple({ userId: user.id, relation: "can_use", object: openFgaObject("tool", toolId), admin: user });
}
}
}
async grantAccessTuple({ userId, relation, object, admin }) {
const result = await this.openFga.writeTuple({ userId, relation, object });
if (result.ok !== false) await this.store.upsertAccessTuple?.({ userId, relation, object, createdByAdminId: admin?.id ?? userId, now: this.now() });
@@ -580,28 +608,67 @@ class AccessController {
return result;
}
withAccessClaims(auth = {}) {
if (!auth?.ok && !auth?.actor) return auth;
const access = this.accessClaimsForActor(auth.actor);
return { ...auth, access };
}
accessClaimsForActor(actor) {
const authenticated = Boolean(actor);
const spec = presetAccessUserForActor(actor, this.env);
const profileId = spec?.navProfile || (actor?.role === "admin" ? ACCESS_DEFAULT_ADMIN_PROFILE : ACCESS_DEFAULT_AUTHENTICATED_PROFILE);
const profile = navProfileByIdFromEnv(this.env, profileId);
const allowedIds = uniqueTextList(profile?.allowedIds ?? (actor?.role === "admin" || authenticated ? [ACCESS_NAV_ALL] : []));
return { nav: { profileId, allowedIds, valuesRedacted: true } };
}
navAccessAllowed(actor, navId) {
const id = textOr(navId, "");
if (!id) return true;
const allowedIds = this.accessClaimsForActor(actor).nav.allowedIds;
return allowedIds.includes(ACCESS_NAV_ALL) || allowedIds.includes(id);
}
async requireNavAccess(request, response, navId) {
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) {
sendJson(response, auth.status, auth);
return null;
}
if (!this.navAccessAllowed(auth.actor, navId)) {
sendJson(response, 403, {
...errorPayload("nav_access_denied", "Navigation access is denied for this route", 403),
navId: textOr(navId, ""),
access: auth.access ?? this.accessClaimsForActor(auth.actor)
});
return null;
}
return auth;
}
async authenticate(request, { required = this.required } = {}) {
await this.ensureBootstrap();
const bearerToken = bearerTokenFromRequest(request);
if (bearerToken && this.userBilling?.configured) {
const delegated = await this.authenticateUserBillingToken(bearerToken);
if (delegated.ok || !isApiKeySecret(bearerToken)) return delegated;
if (delegated.ok || !isApiKeySecret(bearerToken)) return this.withAccessClaims(delegated);
}
const apiKeySecret = apiKeyFromRequest(request);
if (apiKeySecret) return this.authenticateApiKey(apiKeySecret);
if (apiKeySecret) return this.withAccessClaims(await this.authenticateApiKey(apiKeySecret));
const token = sessionCookieFromRequest(request);
if (!token) {
return required ? errorPayload("auth_required", "Authentication is required", 401) : { ok: true, actor: null };
}
if (token.startsWith("hws_") && this.userBilling?.configured) {
return this.authenticateUserBillingToken(token);
return this.withAccessClaims(await this.authenticateUserBillingToken(token));
}
const session = await this.store.findSessionByTokenHash(sha256(token), this.now());
if (!session?.user || session.user.status !== "active") {
return errorPayload("auth_session_invalid", "Session is missing, expired, revoked, or disabled", 401);
}
await this.store.touchSession(session.id, this.now());
return { ok: true, actor: session.user, session: publicSession(session), authMethod: AUTH_METHOD_WEB_SESSION };
return this.withAccessClaims({ ok: true, actor: session.user, session: publicSession(session), authMethod: AUTH_METHOD_WEB_SESSION });
}
async withAuthOtelDependencySpan(name, otelContext, parentSpanId, attributes, fn, classifyResult = null) {
@@ -911,7 +978,7 @@ class AccessController {
const { token, session } = await this.issueWebSessionForActor(user);
setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS, request, this.env);
mark(200, "local_session");
return sendJson(response, 200, sessionResponseFromAuth({ actor: user, session: publicSession({ ...session, user }), authMethod: AUTH_METHOD_WEB_SESSION }));
return sendJson(response, 200, sessionResponseFromAuth(this.withAccessClaims({ actor: user, session: publicSession({ ...session, user }), authMethod: AUTH_METHOD_WEB_SESSION })));
} catch (error) {
httpStatus = Number(error?.statusCode ?? httpStatus ?? 500);
errorCode = textOr(error?.code, "auth_login_error");
@@ -1011,13 +1078,13 @@ class AccessController {
"db.sql.table": "sessions",
"auth.operation": "issue_web_session"
}, () => this.issueWebSessionForActor(auth.actor));
const localAuth = {
const localAuth = this.withAccessClaims({
ok: true,
actor: auth.actor,
session: publicSession({ ...localSession.session, user: auth.actor }),
authMethod: AUTH_METHOD_WEB_SESSION,
userBilling: auth.userBilling ? { active: true, principal: auth.userBilling.principal, valuesRedacted: true } : undefined
};
});
setSessionCookie(response, localSession.token, SESSION_MAX_AGE_SECONDS, request, this.env);
return { ok: true, auth: localAuth, token: localSession.token, upstreamTokenPrefix: redactedTokenPrefix(upstreamToken), status: login.status, retryCount: login.retryCount, transientObserved: login.transientObserved, valuesRedacted: true };
} catch (error) {
@@ -1092,20 +1159,23 @@ class AccessController {
passwordHash,
now: this.now()
});
await this.syncPresetUsers();
await this.syncExistingAdminTuples();
await this.ensureBootstrapAdminApiKey();
return;
}
if (!passwordHash) return;
await this.store.createUser({
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
username: this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin",
displayName: this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin",
role: "admin",
status: "active",
passwordHash,
now: this.now()
});
if (passwordHash) {
await this.store.createUser({
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
username: this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin",
displayName: this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin",
role: "admin",
status: "active",
passwordHash,
now: this.now()
});
}
await this.syncPresetUsers();
await this.syncExistingAdminTuples();
await this.ensureBootstrapAdminApiKey();
}
@@ -1641,6 +1711,7 @@ function canonicalPrincipalFromAuth(auth = {}) {
sessionKind: authenticated ? sessionKindForAuthMethod(authMethod) : null,
actor: authenticated ? publicActor(actor) : null,
capabilities: capabilitySummaryForPrincipal({ actor, authMethod, userBilling: auth.userBilling }),
access: auth.access ?? defaultAccessClaimsForPrincipal({ actor }),
valuesRedacted: true
};
}
@@ -1679,6 +1750,55 @@ function capabilitySummaryForPrincipal({ actor = null, authMethod = null, userBi
admin: admin ? "available" : "forbidden"
};
}
function defaultAccessClaimsForPrincipal({ actor = null } = {}) {
const authenticated = Boolean(actor);
return {
nav: {
profileId: actor?.role === "admin" ? ACCESS_DEFAULT_ADMIN_PROFILE : ACCESS_DEFAULT_AUTHENTICATED_PROFILE,
allowedIds: authenticated ? [ACCESS_NAV_ALL] : [],
valuesRedacted: true
}
};
}
function presetAccessUsersFromEnv(env = process.env) {
return parseJson(textOr(env.HWLAB_ACCESS_CONTROL_USERS_JSON, "[]"), [])
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
.map((item) => ({
id: textOr(item.id, ""),
username: textOr(item.username, ""),
displayName: textOr(item.displayName, item.username ?? ""),
email: textOr(item.email, ""),
role: item.role === "admin" ? "admin" : "user",
status: item.status === "disabled" ? "disabled" : "active",
navProfile: textOr(item.navProfile, ""),
passwordHashEnv: textOr(item.passwordHashEnv, "")
}))
.filter((item) => item.id && item.username);
}
function presetAccessUserForActor(actor, env = process.env) {
if (!actor) return null;
const id = textOr(actor.id, "");
const username = textOr(actor.username, "");
return presetAccessUsersFromEnv(env).find((item) => (id && item.id === id) || (username && item.username === username)) ?? null;
}
function passwordHashForPresetUser(user, env = process.env) {
const envName = textOr(user?.passwordHashEnv, "");
return envName ? textOr(env[envName], "") : "";
}
function navProfilesFromEnv(env = process.env) {
const profiles = parseJson(textOr(env.HWLAB_ACCESS_CONTROL_NAV_PROFILES_JSON, "[]"), []);
return Array.isArray(profiles) ? profiles.filter((item) => item && typeof item === "object" && !Array.isArray(item)) : [];
}
function navProfileByIdFromEnv(env = process.env, profileId) {
const id = textOr(profileId, "");
const found = navProfilesFromEnv(env).find((profile) => textOr(profile.id, "") === id) ?? null;
if (!found) return null;
return { id, allowedIds: uniqueTextList(found.allowedIds) };
}
function uniqueTextList(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.map((item) => textOr(item, "")).filter(Boolean))];
}
function userBillingActor(principal = {}) {
const id = textOr(principal.userId, "");
const username = textOr(principal.username, "") || localUserBillingUsername(id);
+25
View File
@@ -489,6 +489,9 @@ async function handleRpcHttpRequest(request, response, options) {
}
async function handleRestAdapter(request, response, url, options) {
const navId = navIdForRestPath(url.pathname, request.method || "GET");
if (navId && typeof options.accessController?.requireNavAccess === "function" && !(await options.accessController.requireNavAccess(request, response, navId))) return;
if (url.pathname === "/v1/web-performance" && request.method === "POST") {
await handleWebPerformanceIngestHttp(request, response, { store: options.webPerformanceStore, env: options.env });
return;
@@ -946,6 +949,28 @@ async function handleRestAdapter(request, response, url, options) {
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
}
function navIdForRestPath(pathname, method = "GET") {
const verb = String(method || "GET").toUpperCase();
if (pathname === "/v1/usage/summary") return "user.usage";
if (pathname === "/v1/billing/summary" || pathname === "/v1/redeem" || pathname === "/v1/subscription/summary" || pathname === "/v1/payments/summary") return "user.billing";
if (pathname.startsWith("/v1/admin/billing/")) return "admin.billing";
if (pathname === "/v1/skills" || pathname.startsWith("/v1/skills/")) return "system.skills";
if (pathname === "/v1/project-management" || pathname.startsWith("/v1/project-management/")) return "project.mdtodo";
if (pathname === "/v1/api-keys" || pathname === "/v1/api-keys/default" || pathname.startsWith("/v1/api-keys/")) return "user.apiKeys";
if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings";
if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code";
if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code";
if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles";
if (pathname === "/v1/provider-profiles" && verb === "GET") return "workbench.code";
if (pathname.startsWith("/v1/admin/access")) return "admin.access";
if (pathname.startsWith("/v1/admin/users")) return "admin.users";
if (pathname.startsWith("/v1/admin/")) return "admin.access";
if (pathname === "/v1/hwpod-node-ops" || pathname === "/v1/caserun" || pathname.startsWith("/v1/caserun/") || pathname === "/v1/hwpod/specs") return "admin.hwpodGroups";
if (pathname === "/v1/web-performance/metrics" || pathname === "/v1/web-performance/summary" || pathname === "/v1/live-builds") return "system.performance";
if (pathname === "/v1/diagnostics/gate") return "system.gate";
return "";
}
async function handleHwpodNodeOpsHttp(request, response, options) {
if (request.method === "GET") {
sendJson(response, 200, {
+95
View File
@@ -327,6 +327,94 @@ async function readJsonIfPresent(relativePath, fallback = null) {
}
}
async function attachAccessControlEnv(deploy) {
const byProfile = {};
for (const [profile, laneConfig] of Object.entries(deploy?.lanes ?? {})) {
if (!isRuntimeLane(profile)) continue;
const accessControl = laneConfig?.accessControl;
if (!accessControl || typeof accessControl !== "object" || Array.isArray(accessControl)) continue;
const users = await readConfigRef(accessControl.usersRef, `${profile}.accessControl.usersRef`);
const navProfiles = await readConfigRef(accessControl.navProfilesRef, `${profile}.accessControl.navProfilesRef`);
const env = accessControlEnvFromConfig({ users, navProfiles });
if (Object.keys(env).length > 0) byProfile[profile] = { "hwlab-cloud-api": env };
}
Object.defineProperty(deploy, "__accessControlEnvByProfile", { value: byProfile, enumerable: false, configurable: true });
}
async function readConfigRef(ref, label) {
assert.equal(typeof ref, "string", `${label} must be a file reference`);
const [filePath, fragment = ""] = ref.split("#");
assert.ok(filePath, `${label} must include a file path`);
const root = await readJson(filePath);
return configFragment(root, fragment || "", label);
}
function configFragment(value, fragment, label) {
const pathText = String(fragment ?? "").replace(/^\//u, "");
if (!pathText) return value;
return pathText.split(/[./]/u).filter(Boolean).reduce((current, segment) => {
assert.ok(current && typeof current === "object" && Object.hasOwn(current, segment), `${label} fragment is missing ${segment}`);
return current[segment];
}, value);
}
function accessControlEnvFromConfig({ users, navProfiles }) {
assert.ok(Array.isArray(users), "accessControl usersRef must resolve to an array");
assert.ok(Array.isArray(navProfiles), "accessControl navProfilesRef must resolve to an array");
const env = {};
const renderedUsers = users.map((user, index) => normalizeAccessControlUser(user, index, env));
const renderedProfiles = navProfiles.map(normalizeNavProfile);
env.HWLAB_ACCESS_CONTROL_USERS_JSON = JSON.stringify(renderedUsers);
env.HWLAB_ACCESS_CONTROL_NAV_PROFILES_JSON = JSON.stringify(renderedProfiles);
return env;
}
function normalizeAccessControlUser(user, index, env) {
assert.ok(user && typeof user === "object" && !Array.isArray(user), `accessControl user ${index} must be an object`);
const passwordHashRef = user.passwordHashRef && typeof user.passwordHashRef === "object" && !Array.isArray(user.passwordHashRef) ? user.passwordHashRef : null;
const passwordHashEnv = configString(user.passwordHashEnv) || configString(passwordHashRef?.env);
const secretRef = configString(passwordHashRef?.secretRef) || secretRefFromParts(passwordHashRef);
if (passwordHashEnv && secretRef) env[passwordHashEnv] = `secretRef:${secretRef}`;
return {
id: requiredAccessConfigString(user.id, `accessControl user ${index}.id`),
username: requiredAccessConfigString(user.username, `accessControl user ${index}.username`),
displayName: configString(user.displayName) || configString(user.username),
email: configString(user.email) || null,
role: configString(user.role) === "admin" ? "admin" : "user",
status: configString(user.status) === "disabled" ? "disabled" : "active",
navProfile: requiredAccessConfigString(user.navProfile, `accessControl user ${index}.navProfile`),
passwordHashEnv: passwordHashEnv || null
};
}
function normalizeNavProfile(profile, index) {
assert.ok(profile && typeof profile === "object" && !Array.isArray(profile), `accessControl nav profile ${index} must be an object`);
const allowedIds = Array.isArray(profile.allowedIds) ? profile.allowedIds.map(configString).filter(Boolean) : [];
assert.ok(allowedIds.length > 0, `accessControl nav profile ${index}.allowedIds must not be empty`);
return {
id: requiredAccessConfigString(profile.id, `accessControl nav profile ${index}.id`),
displayName: configString(profile.displayName) || configString(profile.id),
allowedIds
};
}
function secretRefFromParts(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const sourceRef = configString(value.sourceRef ?? value.secretName);
const targetKey = configString(value.targetKey ?? value.key);
return sourceRef && targetKey ? `${sourceRef}/${targetKey}` : "";
}
function configString(value) {
return typeof value === "string" ? value.trim() : "";
}
function requiredAccessConfigString(value, label) {
const text = configString(value);
assert.ok(text, `${label} is required`);
return text;
}
async function gitValue(args) {
const result = await execFileAsync("git", args, { cwd: repoRoot, timeout: 10000, maxBuffer: 1024 * 1024 });
return result.stdout.trim();
@@ -997,6 +1085,7 @@ function deployServicesForProfile(deploy, profile) {
copy.env = mergeEnvMaps(
copy.env,
serviceDeclarationEnvForProfile(deploy, profile, service.serviceId),
accessControlEnvForProfile(deploy, profile, service.serviceId),
runtimeStoreEnvForProfile(deploy, profile, service.serviceId),
workbenchRuntimeClientEnvForProfile(deploy, profile, service.serviceId),
workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, service.serviceId),
@@ -1014,6 +1103,7 @@ function deployServicesForProfile(deploy, profile) {
serviceId: override.serviceId,
env: mergeEnvMaps(
serviceDeclarationEnvForProfile(deploy, profile, override.serviceId),
accessControlEnvForProfile(deploy, profile, override.serviceId),
runtimeStoreEnvForProfile(deploy, profile, override.serviceId),
workbenchRuntimeClientEnvForProfile(deploy, profile, override.serviceId),
workbenchRuntimeFactsQueryEnvForProfile(deploy, profile, override.serviceId),
@@ -1031,6 +1121,10 @@ function deployServicesForProfile(deploy, profile) {
return services;
}
function accessControlEnvForProfile(deploy, profile, serviceId) {
return deploy?.__accessControlEnvByProfile?.[profile]?.[serviceId] ?? {};
}
function serviceDeclarationEnvForProfile(deploy, profile, serviceId) {
if (!isRuntimeLane(profile)) return {};
const env = deploy?.lanes?.[profile]?.serviceDeclarations?.[serviceId]?.env;
@@ -5498,6 +5592,7 @@ async function plannedFiles(args) {
defaultV02RuntimeEndpoint,
defaultSourceRepo
});
await attachAccessControlEnv(deploy);
const source = await resolveSourceRevision(args.sourceRevision);
source.imageTag = imageTagForSource(args, source);
let artifactCatalog = await readJsonIfPresent(args.catalogPath, null);
@@ -18,16 +18,32 @@ const app = useAppStore();
const workbench = useWorkbenchStore();
const diagnosticsOpen = ref(false);
interface NavItem {
name: string;
label: string;
path: string;
icon: string;
navId: string;
}
interface NavSection {
title: string;
items: NavItem[];
}
const navSections = [
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D" }] },
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M" }] },
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K" }, { name: "Usage", label: "用量", path: "/usage", icon: "U" }, { name: "Billing", label: "充值", path: "/billing", icon: "$" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R" }] },
{ title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G" }, { name: "Performance", label: "性能", path: "/performance", icon: "M" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S" }, { name: "Settings", label: "设置", path: "/settings", icon: "T" }, { name: "Help", label: "帮助", path: "/help", icon: "?" }] }
];
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] },
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] },
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] },
{ title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G", navId: "system.gate" }, { name: "Performance", label: "性能", path: "/performance", icon: "M", navId: "system.performance" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S", navId: "system.skills" }, { name: "Settings", label: "设置", path: "/settings", icon: "T", navId: "system.settings" }, { name: "Help", label: "帮助", path: "/help", icon: "?", navId: "system.help" }] }
] satisfies NavSection[];
const shellless = computed(() => route.name === "Login" || route.name === "Register" || route.name === "NotFound");
const showWorkbenchDiagnostics = computed(() => route.meta.section === "workbench");
const visibleNavSections = computed(() => navSections
.map((section) => ({ ...section, items: section.items.filter((item) => auth.canAccessNav(item.navId)) }))
.filter((section) => section.items.length > 0));
watch(showWorkbenchDiagnostics, (visible) => {
if (!visible) diagnosticsOpen.value = false;
@@ -43,7 +59,7 @@ async function go(path: string): Promise<void> {
}
}
function isNavItemActive(item: { name: string }): boolean {
function isNavItemActive(item: NavItem): boolean {
return route.name === item.name || (item.name === "CodeWorkbench" && route.meta.section === "workbench");
}
</script>
@@ -62,7 +78,7 @@ function isNavItemActive(item: { name: string }): boolean {
</div>
</div>
<nav class="nav-groups" aria-label="主导航">
<section v-for="section in navSections" :key="section.title" class="nav-section">
<section v-for="section in visibleNavSections" :key="section.title" class="nav-section">
<h2>{{ section.title }}</h2>
<button v-for="item in section.items" :key="item.name" class="nav-item" :data-active="isNavItemActive(item)" type="button" :title="item.label" :aria-label="item.label" @click="go(item.path)">
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
+1
View File
@@ -31,6 +31,7 @@ export function installRouterGuards(router: Router): void {
const auth = useAuthStore();
if (!auth.checked) await auth.bootstrap();
if (to.meta.requiresAuth !== false && auth.authState === "login") return { name: "Login", query: { redirect: to.fullPath } };
if (to.meta.requiresAuth !== false && to.meta.navId && !auth.canAccessNav(to.meta.navId)) return { path: auth.firstAllowedPath, query: { blocked: "nav_access_denied" } };
if (to.meta.requiresAdmin === true && !auth.isAdmin) return { name: "Dashboard", query: { blocked: "admin_required" } };
return true;
});
+18 -18
View File
@@ -12,24 +12,24 @@ const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/workbench" },
{ path: "/login", name: "Login", component: () => import("@/views/LoginView.vue"), meta: { requiresAuth: false, title: "登录" } },
{ path: "/register", name: "Register", component: () => import("@/views/RegisterView.vue"), meta: { requiresAuth: false, title: "注册" } },
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, title: "平台概览", section: "user" } },
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
{ path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, title: "项目", section: "project" } },
{ path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, title: "MDTODO", section: "project" } },
{ path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, title: "API Keys", section: "user" } },
{ path: "/usage", name: "Usage", component: () => import("@/views/user/UsageView.vue"), meta: { requiresAuth: true, title: "用量与性能", section: "user" } },
{ path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, title: "充值与订阅", section: "user" } },
{ path: "/performance", name: "Performance", component: () => import("@/views/PerformanceView.vue"), meta: { requiresAuth: true, title: "性能监控", section: "system" } },
{ path: "/admin/access", alias: ["/access"], name: "Access", component: () => import("@/views/admin/AccessView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "授权管理", section: "admin" } },
{ path: "/admin/users", name: "Users", component: () => import("@/views/admin/UsersView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "用户管理", section: "admin" } },
{ path: "/admin/billing", name: "AdminBilling", component: () => import("@/views/admin/BillingView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "兑换与订阅", section: "admin" } },
{ path: "/admin/hwpod-groups", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "HWPOD 分组", section: "admin" } },
{ path: "/admin/provider-profiles", name: "ProviderProfiles", component: () => import("@/views/admin/ProviderProfilesView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "Provider Profiles", section: "admin" } },
{ path: "/skills", name: "Skills", component: () => import("@/views/SkillsView.vue"), meta: { requiresAuth: true, title: "技能包", section: "system" } },
{ path: "/settings", name: "Settings", component: () => import("@/views/SettingsView.vue"), meta: { requiresAuth: true, title: "设置", section: "system" } },
{ path: "/gate", alias: ["/diagnostics/gate"], name: "Gate", component: () => import("@/views/GateView.vue"), meta: { requiresAuth: true, title: "内部复核", section: "system" } },
{ path: "/help", name: "Help", component: () => import("@/views/HelpView.vue"), meta: { requiresAuth: true, title: "帮助", section: "system" } },
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, navId: "user.dashboard", title: "平台概览", section: "user" } },
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
{ path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, navId: "project.overview", title: "项目", section: "project" } },
{ path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
{ path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, navId: "user.apiKeys", title: "API Keys", section: "user" } },
{ path: "/usage", name: "Usage", component: () => import("@/views/user/UsageView.vue"), meta: { requiresAuth: true, navId: "user.usage", title: "用量与性能", section: "user" } },
{ path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, navId: "user.billing", title: "充值与订阅", section: "user" } },
{ path: "/performance", name: "Performance", component: () => import("@/views/PerformanceView.vue"), meta: { requiresAuth: true, navId: "system.performance", title: "性能监控", section: "system" } },
{ path: "/admin/access", alias: ["/access"], name: "Access", component: () => import("@/views/admin/AccessView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.access", title: "授权管理", section: "admin" } },
{ path: "/admin/users", name: "Users", component: () => import("@/views/admin/UsersView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.users", title: "用户管理", section: "admin" } },
{ path: "/admin/billing", name: "AdminBilling", component: () => import("@/views/admin/BillingView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.billing", title: "兑换与订阅", section: "admin" } },
{ path: "/admin/hwpod-groups", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", title: "HWPOD 分组", section: "admin" } },
{ path: "/admin/provider-profiles", name: "ProviderProfiles", component: () => import("@/views/admin/ProviderProfilesView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.providerProfiles", title: "Provider Profiles", section: "admin" } },
{ path: "/skills", name: "Skills", component: () => import("@/views/SkillsView.vue"), meta: { requiresAuth: true, navId: "system.skills", title: "技能包", section: "system" } },
{ path: "/settings", name: "Settings", component: () => import("@/views/SettingsView.vue"), meta: { requiresAuth: true, navId: "system.settings", title: "设置", section: "system" } },
{ path: "/gate", alias: ["/diagnostics/gate"], name: "Gate", component: () => import("@/views/GateView.vue"), meta: { requiresAuth: true, navId: "system.gate", title: "内部复核", section: "system" } },
{ path: "/help", name: "Help", component: () => import("@/views/HelpView.vue"), meta: { requiresAuth: true, navId: "system.help", title: "帮助", section: "system" } },
{ path: "/:pathMatch(.*)*", name: "NotFound", component: () => import("@/views/NotFoundView.vue"), meta: { requiresAuth: false, title: "未找到" } }
];
+1
View File
@@ -7,6 +7,7 @@ declare module "vue-router" {
interface RouteMeta {
requiresAuth?: boolean;
requiresAdmin?: boolean;
navId?: string;
title?: string;
section?: "workbench" | "project" | "admin" | "user" | "system";
}
+34 -3
View File
@@ -1,7 +1,7 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { authAPI, HWLAB_WEB_SESSION_COOKIE } from "@/api";
import type { AuthCapabilities, AuthCapabilityStatus, AuthSession, AuthState, AuthUser } from "@/types";
import type { AuthAccess, AuthCapabilities, AuthCapabilityStatus, AuthSession, AuthState, AuthUser } from "@/types";
import { asRecord, nonEmptyString } from "@/utils";
export const useAuthStore = defineStore("auth", () => {
@@ -13,6 +13,15 @@ export const useAuthStore = defineStore("auth", () => {
const isAuthenticated = computed(() => authState.value === "authenticated" && session.value?.authenticated === true);
const user = computed(() => session.value?.user ?? session.value?.actor ?? null);
const isAdmin = computed(() => session.value?.capabilities?.admin === "available" || user.value?.role === "admin");
const allowedNavIds = computed(() => session.value?.access?.nav?.allowedIds ?? []);
const firstAllowedPath = computed(() => firstAllowedNavPath(allowedNavIds.value));
function canAccessNav(navId: unknown): boolean {
const id = nonEmptyString(navId);
if (!id) return true;
const allowed = allowedNavIds.value;
return allowed.includes("*") || allowed.includes(id);
}
async function bootstrap(): Promise<void> {
if (authState.value !== "checking") checked.value = true;
@@ -77,10 +86,10 @@ export const useAuthStore = defineStore("auth", () => {
document.body.dataset.authState = "login";
}
return { authState, checked, session, error, isAuthenticated, user, isAdmin, bootstrap, login, register, logout, cookieName: HWLAB_WEB_SESSION_COOKIE };
return { authState, checked, session, error, isAuthenticated, user, isAdmin, allowedNavIds, firstAllowedPath, canAccessNav, bootstrap, login, register, logout, cookieName: HWLAB_WEB_SESSION_COOKIE };
});
function sessionFromPayload(payload: { authenticated?: boolean; authMethod?: unknown; identityAuthority?: unknown; sessionKind?: unknown; user?: unknown; actor?: unknown; capabilities?: unknown; expiresAt?: string | null; sessionExpiresAt?: string | null; valuesRedacted?: unknown }): AuthSession {
function sessionFromPayload(payload: { authenticated?: boolean; authMethod?: unknown; identityAuthority?: unknown; sessionKind?: unknown; user?: unknown; actor?: unknown; capabilities?: unknown; access?: unknown; expiresAt?: string | null; sessionExpiresAt?: string | null; valuesRedacted?: unknown }): AuthSession {
return {
authenticated: payload.authenticated === true,
mode: "server",
@@ -90,6 +99,7 @@ function sessionFromPayload(payload: { authenticated?: boolean; authMethod?: unk
user: userFromUnknown(payload.user),
actor: userFromUnknown(payload.actor),
capabilities: capabilitiesFromUnknown(payload.capabilities),
access: accessFromUnknown(payload.access),
expiresAt: nonEmptyString(payload.expiresAt) ?? nonEmptyString(payload.sessionExpiresAt),
valuesRedacted: payload.valuesRedacted === true
};
@@ -112,6 +122,27 @@ function capabilitiesFromUnknown(value: unknown): AuthCapabilities | undefined {
return Object.keys(output).length > 0 ? output : undefined;
}
function accessFromUnknown(value: unknown): AuthAccess | undefined {
const record = asRecord(value);
const nav = asRecord(record?.nav);
if (!nav) return undefined;
const allowedIds = Array.isArray(nav.allowedIds) ? nav.allowedIds.map(nonEmptyString).filter((item): item is string => Boolean(item)) : [];
return { nav: { profileId: nonEmptyString(nav.profileId) ?? undefined, allowedIds, valuesRedacted: nav.valuesRedacted === true } };
}
function firstAllowedNavPath(allowedIds: string[]): string {
const ordered = [
{ id: "workbench.code", path: "/workbench" },
{ id: "project.mdtodo", path: "/projects/mdtodo" },
{ id: "user.dashboard", path: "/dashboard" },
{ id: "project.overview", path: "/projects" },
{ id: "user.billing", path: "/billing" },
{ id: "system.settings", path: "/settings" }
];
if (allowedIds.includes("*")) return "/workbench";
return ordered.find((item) => allowedIds.includes(item.id))?.path ?? "/workbench";
}
function loginFailureMessage(response: Awaited<ReturnType<typeof authAPI.login>>): string {
const code = loginErrorCode(response.data) ?? nonEmptyString(response.error);
const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? "";
+12
View File
@@ -31,6 +31,17 @@ export interface AuthCapabilities {
[key: string]: AuthCapabilityStatus | undefined;
}
export interface AuthNavAccess {
profileId?: string;
allowedIds?: string[];
valuesRedacted?: boolean;
}
export interface AuthAccess {
nav?: AuthNavAccess;
[key: string]: unknown;
}
export interface AuthSession {
authenticated: boolean;
mode: "server";
@@ -40,6 +51,7 @@ export interface AuthSession {
user?: AuthUser;
actor?: AuthUser;
capabilities?: AuthCapabilities;
access?: AuthAccess;
expiresAt?: string | null;
valuesRedacted?: boolean;
}