diff --git a/web/auth/README.md b/web/auth/README.md
new file mode 100644
index 0000000..d0c81a3
--- /dev/null
+++ b/web/auth/README.md
@@ -0,0 +1,56 @@
+# web/auth — iAOP 登录认证 + 用户/角色管理
+
+issue #134 / PRD 8.2 认证鉴权。纯静态(无构建链)Demo 级实现:
+登录页 + 会话管理 + 用户/角色管理 + 三级 RBAC 门控 + OIDC SSO 预留。
+
+## 文件
+
+```
+web/auth/
+├── auth.js # 共享库:用户 CRUD(加盐 SHA-256)/ 会话 / 权限矩阵 / 审计 / OIDC 配置点
+├── login.html # 登录页(本地账号;配置 OIDC 后出现 SSO 入口)
+├── users.html # 用户/角色管理页(仅 admin)+ OIDC 配置 + 审计日志查看
+├── users.js # 管理页逻辑
+└── README.md
+```
+
+## 使用
+
+```bash
+# 从 web/ 根目录起服务(studio 集成依赖 ../auth 相对路径)
+cd web && python -m http.server 8083
+# 打开 http://127.0.0.1:8083/auth/login.html
+```
+
+首次运行内置管理员 **admin / admin123**(登录后请立即在用户管理页改密)。
+未登录访问 `web/studio/`(模板配置台)会自动跳转登录页,登录后按会话角色
+应用三级 RBAC(Viewer 只读 / Editor 配置 / Publisher 发布+回滚)。
+
+## 语义对齐
+
+| 能力 | 对齐后端 |
+|------|----------|
+| 角色 readonly / engineer / admin,动作 view / edit / publish / manage | `core/template-console/rbac.py`(Resource/Action/RoleKind) |
+| 用户数据(username/role/enabled/salt/hash) | `core/data-bus/config/postgres.template.yaml` users/permissions 表(Demo 落 localStorage,后端 HTTP 就绪后替换 `UserStore` 读写即可) |
+| 写操作审计(时间/操作人/动作/资源/原因) | `template_registry._log` 风格 + `prompts.drain_audit` 语义 |
+| OIDC 配置点(issuer/client_id/redirect_uri) | PRD 8.2 双轨:未配置走本地账号,配置后登录页出现 SSO 入口(标准授权码流程 authorize URL 已可生成;令牌换会话的后端端点待企业 IAM 就绪后对接) |
+
+## 各前端页面接入方式
+
+```html
+
+
+```
+
+`web/studio` 已接入(未登录不可进入,写操作按会话角色门控,发布/回滚落审计);
+auth.js 缺失时 studio 降级为手动角色切换演示,向后兼容。
+
+## 安全说明(Demo 边界)
+
+- 密码加盐 SHA-256(Web Crypto)存储,不落明文;会话 token 8 小时过期,
+ 账号禁用/删除即时失效;
+- localStorage 存储仅用于无后端 Demo;生产部署需由后端接管 users 表
+ (PostgreSQL)与会话签发,本库 API(login/session/can/audit)保持不变。
diff --git a/web/auth/auth.js b/web/auth/auth.js
new file mode 100644
index 0000000..328919a
--- /dev/null
+++ b/web/auth/auth.js
@@ -0,0 +1,193 @@
+/* iAOP 认证鉴权共享库(issue #134 / PRD 8.2)。
+ *
+ * 纯前端 Demo 级实现(无构建链、无后端 HTTP 服务),语义对齐
+ * core/template-console/rbac.py(readonly/engineer/admin 三级角色):
+ * - 本地账号:密码加盐 SHA-256 哈希存储(Web Crypto),落 localStorage
+ * (数据模型对齐 core/data-bus postgres.template.yaml 的 users/permissions 表,
+ * 后端 HTTP 服务就绪后仅需替换 UserStore 三处读写);
+ * - 会话管理:登录签发会话 token(8h 过期),各页面 Auth.requireAuth() 门控;
+ * - 权限矩阵:Auth.can(role, action),action ∈ view/edit/publish/manage;
+ * - 审计日志:写操作落 Auth.audit()(复用 template_registry._log 风格:
+ * 时间/操作人/动作/资源/原因),users.html 可查看;
+ * - 预留企业 IAM/OAuth2(OIDC SSO):Auth.configureOidc({issuer, client_id,
+ * redirect_uri}),配置后登录页出现「SSO 登录」按钮并跳转 authorize URL;
+ * 未配置走本地账号(PRD 8.2 双轨)。
+ */
+"use strict";
+
+var Auth = (function () {
+ var USERS_KEY = "iaop.users.v1";
+ var SESSION_KEY = "iaop.session.v1";
+ var AUDIT_KEY = "iaop.audit.v1";
+ var OIDC_KEY = "iaop.oidc.v1";
+ var SESSION_TTL_MS = 8 * 3600 * 1000; // 会话 8 小时
+
+ /* 权限矩阵:对齐 rbac.py(VIEW < EDIT < PUBLISH < MANAGE,角色继承向上) */
+ var ROLE_PERMS = {
+ readonly: ["view"],
+ engineer: ["view", "edit"],
+ admin: ["view", "edit", "publish", "manage"]
+ };
+ var ROLE_LABELS = { readonly: "Viewer(只读)", engineer: "Editor(配置)",
+ admin: "Publisher(发布+回滚)" };
+
+ function load(key, fallback) {
+ try { var v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; }
+ catch (e) { return fallback; }
+ }
+ function save(key, obj) { localStorage.setItem(key, JSON.stringify(obj)); }
+
+ /* 密码加盐 SHA-256(Web Crypto;落库字段:salt + hash,不存明文) */
+ function hashPassword(password, salt) {
+ var data = new TextEncoder().encode(salt + ":" + password);
+ return crypto.subtle.digest("SHA-256", data).then(function (buf) {
+ return Array.from(new Uint8Array(buf))
+ .map(function (b) { return b.toString(16).padStart(2, "0"); }).join("");
+ });
+ }
+ function randomHex(n) {
+ var buf = new Uint8Array(n);
+ crypto.getRandomValues(buf);
+ return Array.from(buf).map(function (b) { return b.toString(16).padStart(2, "0"); }).join("");
+ }
+
+ /* 首次运行播种内置管理员(admin / admin123,登录后请立即改密) */
+ function ensureSeed() {
+ var users = load(USERS_KEY, null);
+ if (users) return Promise.resolve();
+ var salt = randomHex(16);
+ return hashPassword("admin123", salt).then(function (hash) {
+ save(USERS_KEY, [{ username: "admin", role: "admin", enabled: true,
+ salt: salt, hash: hash,
+ created_at: new Date().toISOString() }]);
+ });
+ }
+
+ return {
+ ROLES: Object.keys(ROLE_PERMS),
+ ROLE_LABELS: ROLE_LABELS,
+
+ /* ---------- 用户 CRUD(users 表语义:username/role/enabled/salt/hash) ---------- */
+ listUsers: function () { return load(USERS_KEY, []); },
+ findUser: function (username) {
+ return this.listUsers().find(function (u) { return u.username === username; }) || null;
+ },
+ createUser: function (username, password, role) {
+ var self = this;
+ if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) {
+ return Promise.reject(new Error("用户名需 2-32 位字母数字._-"));
+ }
+ if (this.findUser(username)) return Promise.reject(new Error("用户名已存在"));
+ if (ROLE_PERMS[role] === undefined) return Promise.reject(new Error("非法角色"));
+ if ((password || "").length < 6) return Promise.reject(new Error("密码至少 6 位"));
+ var salt = randomHex(16);
+ return hashPassword(password, salt).then(function (hash) {
+ var users = self.listUsers();
+ users.push({ username: username, role: role, enabled: true,
+ salt: salt, hash: hash, created_at: new Date().toISOString() });
+ save(USERS_KEY, users);
+ self.audit("create", "user", username + " 角色=" + role);
+ });
+ },
+ updateUser: function (username, patch) {
+ var users = this.listUsers();
+ var u = users.find(function (x) { return x.username === username; });
+ if (!u) return Promise.reject(new Error("用户不存在"));
+ var self = this;
+ var done = Promise.resolve();
+ if (patch.role) {
+ if (ROLE_PERMS[patch.role] === undefined) return Promise.reject(new Error("非法角色"));
+ u.role = patch.role;
+ }
+ if (patch.enabled !== undefined) u.enabled = !!patch.enabled;
+ if (patch.password) {
+ if (patch.password.length < 6) return Promise.reject(new Error("密码至少 6 位"));
+ u.salt = randomHex(16);
+ done = hashPassword(patch.password, u.salt).then(function (h) { u.hash = h; });
+ }
+ return done.then(function () {
+ save(USERS_KEY, users);
+ self.audit("update", "user", username + " " + JSON.stringify(
+ { role: patch.role, enabled: patch.enabled, password: !!patch.password }));
+ });
+ },
+ deleteUser: function (username) {
+ var users = this.listUsers().filter(function (u) { return u.username !== username; });
+ save(USERS_KEY, users);
+ this.audit("delete", "user", username);
+ return Promise.resolve();
+ },
+
+ /* ---------- 会话 ---------- */
+ login: function (username, password) {
+ var u = this.findUser(username);
+ if (!u) return Promise.reject(new Error("用户不存在"));
+ if (!u.enabled) return Promise.reject(new Error("账号已禁用,请联系管理员"));
+ var self = this;
+ return hashPassword(password, u.salt).then(function (hash) {
+ if (hash !== u.hash) throw new Error("密码错误");
+ var session = { username: u.username, role: u.role, token: randomHex(24),
+ expires: Date.now() + SESSION_TTL_MS };
+ save(SESSION_KEY, session);
+ self.audit("login", "session", u.username);
+ return session;
+ });
+ },
+ logout: function () {
+ var s = this.session();
+ if (s) this.audit("logout", "session", s.username);
+ localStorage.removeItem(SESSION_KEY);
+ },
+ session: function () {
+ var s = load(SESSION_KEY, null);
+ if (!s || !s.expires || s.expires < Date.now()) return null;
+ var u = this.findUser(s.username);
+ if (!u || !u.enabled) return null; // 禁用/删除即时失效
+ return s;
+ },
+ /* 页面门控:未登录 → 跳登录页(带 next 回跳);登录后角色变化以会话为准 */
+ requireAuth: function (loginPage) {
+ var s = this.session();
+ if (!s) {
+ var next = encodeURIComponent(location.pathname.split("/").pop() || "index.html");
+ location.href = (loginPage || "../auth/login.html") + "?next=" + next;
+ return null;
+ }
+ return s;
+ },
+ can: function (role, action) {
+ return (ROLE_PERMS[role] || []).indexOf(action) >= 0;
+ },
+
+ /* ---------- 审计日志(template_registry._log 风格:时间/操作人/动作/资源/原因) ---------- */
+ audit: function (action, resource, reason) {
+ var log = load(AUDIT_KEY, []);
+ var s = this.session();
+ log.push({ time: new Date().toISOString(),
+ actor: s ? s.username : "anonymous",
+ action: action, resource: resource, reason: reason || "" });
+ if (log.length > 500) log = log.slice(-500); // 演示环境限长
+ save(AUDIT_KEY, log);
+ },
+ auditLog: function () { return load(AUDIT_KEY, []); },
+
+ /* ---------- OIDC SSO 预留(PRD 8.2 双轨:未配置走本地账号) ---------- */
+ configureOidc: function (cfg) { save(OIDC_KEY, cfg); this.audit("update", "oidc_config", cfg.issuer || ""); },
+ oidcConfig: function () { return load(OIDC_KEY, null); },
+ /* 构造 OIDC authorize URL(标准授权码流程;企业 IAM 就绪后直接可用) */
+ oidcAuthorizeUrl: function () {
+ var cfg = this.oidcConfig();
+ if (!cfg || !cfg.issuer || !cfg.client_id) return null;
+ var redirect = cfg.redirect_uri ||
+ (location.origin + location.pathname.replace(/[^/]*$/, "login.html"));
+ var state = randomHex(8);
+ sessionStorage.setItem("iaop.oidc.state", state);
+ return cfg.issuer.replace(/\/$/, "") + "/authorize?response_type=code" +
+ "&client_id=" + encodeURIComponent(cfg.client_id) +
+ "&redirect_uri=" + encodeURIComponent(redirect) +
+ "&scope=openid%20profile&state=" + state;
+ },
+
+ _ensureSeed: ensureSeed
+ };
+})();
diff --git a/web/auth/login.html b/web/auth/login.html
new file mode 100644
index 0000000..856a434
--- /dev/null
+++ b/web/auth/login.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+iAOP 登录
+
+
+
+
+
iAOP 平台登录
+
本地账号 / 企业 IAM(SSO) 双轨 · PRD 8.2
+
+
+
+
+
+
+
+
首次运行内置管理员 admin / admin123,登录后请在用户管理页修改密码。
+ SSO 未配置时仅本地账号可用;配置 OIDC(issuer/client_id)后此处出现 SSO 入口。
+
+
+
+
+
diff --git a/web/auth/users.html b/web/auth/users.html
new file mode 100644
index 0000000..7617dae
--- /dev/null
+++ b/web/auth/users.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+iAOP 用户/角色管理
+
+
+
+
+ 用户/角色管理 RBAC 三级:readonly / engineer / admin · PRD 8.2
+
+
+
+
+
用户列表
+
+
+
+
+
+
+
+
+
+
+
+
+
审计日志(写操作落日志,最近 500 条)
+
+
+
+
+
+
+
+
diff --git a/web/auth/users.js b/web/auth/users.js
new file mode 100644
index 0000000..777c1bb
--- /dev/null
+++ b/web/auth/users.js
@@ -0,0 +1,118 @@
+/* iAOP 用户/角色管理页逻辑(issue #134)。
+ * 仅 admin(Publisher + MANAGE 权限)可访问;其余角色跳回登录/配置台。
+ */
+"use strict";
+
+(function () {
+ var session = Auth.requireAuth("login.html");
+ if (!session) return;
+ if (!Auth.can(session.role, "manage")) {
+ alert("仅 Publisher(admin)可访问用户管理");
+ location.href = "../studio/index.html";
+ return;
+ }
+ document.getElementById("who").textContent =
+ session.username + " · " + Auth.ROLE_LABELS[session.role];
+
+ function el(tag, text) {
+ var n = document.createElement(tag);
+ if (text !== undefined) n.textContent = text;
+ return n;
+ }
+
+ function renderUsers() {
+ var tbody = document.getElementById("user-tbody");
+ tbody.innerHTML = "";
+ Auth.listUsers().forEach(function (u) {
+ var tr = el("tr");
+ if (!u.enabled) tr.className = "disabled-user";
+ tr.appendChild(el("td", u.username));
+ // 角色分配(下拉即改)
+ var tdRole = el("td");
+ var sel = el("select");
+ Auth.ROLES.forEach(function (r) {
+ var opt = el("option", Auth.ROLE_LABELS[r]);
+ opt.value = r;
+ if (r === u.role) opt.selected = true;
+ sel.appendChild(opt);
+ });
+ sel.className = "role-" + u.role;
+ sel.onchange = function () {
+ Auth.updateUser(u.username, { role: sel.value }).then(renderUsers);
+ };
+ tdRole.appendChild(sel);
+ tr.appendChild(tdRole);
+ tr.appendChild(el("td", u.enabled ? "启用" : "禁用"));
+ tr.appendChild(el("td", (u.created_at || "").slice(0, 10)));
+ var tdOps = el("td");
+ var toggle = el("button", u.enabled ? "禁用" : "启用");
+ toggle.onclick = function () {
+ Auth.updateUser(u.username, { enabled: !u.enabled }).then(renderUsers);
+ };
+ var reset = el("button", "重置密码");
+ reset.onclick = function () {
+ var pwd = prompt("为 " + u.username + " 设置新密码(≥6 位)");
+ if (pwd) Auth.updateUser(u.username, { password: pwd }).then(renderUsers);
+ };
+ var del = el("button", "删除");
+ del.className = "danger";
+ del.onclick = function () {
+ if (u.username === session.username) { alert("不能删除当前登录账号"); return; }
+ if (confirm("确认删除用户 " + u.username + "?")) {
+ Auth.deleteUser(u.username).then(renderUsers);
+ }
+ };
+ [toggle, reset, del].forEach(function (b) {
+ b.style.marginRight = "4px"; tdOps.appendChild(b);
+ });
+ tr.appendChild(tdOps);
+ tbody.appendChild(tr);
+ });
+ renderAudit();
+ }
+
+ function renderAudit() {
+ var box = document.getElementById("audit-list");
+ box.innerHTML = "";
+ Auth.auditLog().slice().reverse().forEach(function (e) {
+ box.appendChild(el("div",
+ e.time.slice(0, 19).replace("T", " ") + " | " + e.actor + " | " +
+ e.action + " | " + e.resource + " | " + e.reason));
+ });
+ }
+
+ document.getElementById("btn-add").onclick = function () {
+ var err = document.getElementById("form-err");
+ err.textContent = "";
+ Auth.createUser(document.getElementById("new-username").value.trim(),
+ document.getElementById("new-password").value,
+ document.getElementById("new-role").value)
+ .then(function () {
+ document.getElementById("new-username").value = "";
+ document.getElementById("new-password").value = "";
+ renderUsers();
+ })
+ .catch(function (e) { err.textContent = e.message; });
+ };
+
+ // OIDC 配置点
+ var cfg = Auth.oidcConfig() || {};
+ document.getElementById("oidc-issuer").value = cfg.issuer || "";
+ document.getElementById("oidc-client").value = cfg.client_id || "";
+ document.getElementById("oidc-redirect").value = cfg.redirect_uri || "";
+ document.getElementById("btn-oidc").onclick = function () {
+ Auth.configureOidc({
+ issuer: document.getElementById("oidc-issuer").value.trim(),
+ client_id: document.getElementById("oidc-client").value.trim(),
+ redirect_uri: document.getElementById("oidc-redirect").value.trim()
+ });
+ alert("OIDC 配置已保存;登录页将出现 SSO 入口");
+ };
+
+ document.getElementById("logout-btn").onclick = function () {
+ Auth.logout();
+ location.href = "login.html";
+ };
+
+ renderUsers();
+})();
diff --git a/web/studio/index.html b/web/studio/index.html
index faab1f9..ce881fa 100644
--- a/web/studio/index.html
+++ b/web/studio/index.html
@@ -145,6 +145,7 @@
+