用户与权限管理(3):管理模板与 Automation 对象授权
用户与权限管理(3):管理模板与 Automation 对象授权
教程版本:v2.0.0;定稿日期:2026-10-08。
本篇先补齐第 2 篇管理端的完整模板、执行顺序和验收清单,再把全局 Capability 与 Automation 的对象 AccessGrant 组合起来。普通用户只有同时具备全局能力和指定对象动作才被允许;不可见对象返回 404,对象可见但动作不足返回 403。第三方身份从第 4 篇开始,避免把两条安全主线挤在同一篇。
| 篇次 | 固定地址 | 内容 |
|---|---|---|
| 第 1 篇 | https://www.cnblogs.com/lizexiong/p/22694083 | 本地登录、自研能力、角色、用户角色与第一次授权 |
| 第 2 篇 | https://www.cnblogs.com/lizexiong/p/22712552 | Policy API、管理后端、并发复检与 CMDB 全局能力 |
| 第 3 篇 | https://www.cnblogs.com/lizexiong/p/22712579 | 本文:完整管理模板与 Automation 对象授权 |
| 第 4 篇 | https://www.cnblogs.com/lizexiong/p/22716152 | 第三方身份、迁移、完整源码与校验 |
1. 本篇交付边界与验证事实
1.1 先完成管理界面,再进入对象授权
第 2 篇已经完成 Policy、表单、视图、路由、Admin 和 bootstrap。本篇第 2~4 章补齐与这些后端逐字匹配的完整模板、执行顺序和验收清单;从第 5 章起再接入 Automation。这样不会把一个模板或函数从中间截断,也不会为了篇幅删除初学者需要的代码内说明。
1.2 Automation 使用两道独立门
全局 Capability 回答“能不能做这一类动作”,对象 AccessGrant 回答“能不能操作这一个定义、清单或凭据”。普通用户必须同时通过两道门;对象授权不能制造全局能力,全局能力也不能自动打开全部对象。
1.3 本篇采用的验证记录
- 账号教学项目:
90 tests OK,另有2 MySQL-only skipped。 - Automation/CMDB 集成:
262 tests OK。 - SQLite 通过不能写成 MySQL 并发已经验证;真实 Provider 也不属于本篇。
2. 完整模板:policy_can 负责可见性,视图负责安全
以下模板全部给出阶段完整内容。每个列表页或详情页只根据能力决定链接和按钮是否显示;写 URL 本身仍由第 8 章视图保护。系统角色页面明确标注“由初始化命令维护”。能力目录沿用历史文件名 permission_list.html 和路由名 permission_list,但页面展示的是项目自有 Capability。
完整文件:templates/base.html
{# templates/base.html #}
{# load 会加载 static 与 policy_tags 标签库,后面才能使用静态文件标签和自定义 policy_can 标签。 #}
{% load static policy_tags %}
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# block 声明可由子模板覆盖的区块;未覆盖 title 时使用这里的默认标题。 #}
<title>{% block title %}devopsX 用户与访问控制{% endblock %}</title>
<link rel="stylesheet" href="{% static 'accounts/style.css' %}">
</head>
<body>
<header class="site-header">
<div class="container header-row">
{# url 根据 accounts:home 路由名反向生成地址,路由前缀变化时无需修改模板。 #}
<a class="brand" href="{% url 'accounts:home' %}">devopsX</a>
{# if 是模板条件判断;匿名用户不会进入这一分支,也看不到业务导航。 #}
{% if user.is_authenticated %}
{% policy_can "accounts.user.view" as can_view_users %}
{% policy_can "accounts.department.view" as can_view_departments %}
{% policy_can "accounts.role.view" as can_view_roles %}
{% policy_can "accounts.capability.view" as can_view_capabilities %}
<nav aria-label="主导航">
<a href="{% url 'accounts:home' %}">首页</a>
{% if can_view_users %}<a href="{% url 'accounts:user_list' %}">用户</a>{% endif %}
{% if can_view_departments %}<a href="{% url 'accounts:department_list' %}">部门</a>{% endif %}
{% if can_view_roles %}<a href="{% url 'accounts:role_list' %}">角色</a>{% endif %}
{% if can_view_capabilities %}<a href="{% url 'accounts:permission_list' %}">能力目录</a>{% endif %}
</nav>
<div class="account-actions">
<span>当前用户:{{ user }}</span>
<a href="{% url 'accounts:password_change' %}">修改密码</a>
<form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
{# csrf_token 输出与当前 Session 绑定的防伪字段,保护会改变登录状态的 POST 退出请求。 #}
{% csrf_token %}
<button type="submit" class="link-button">退出</button>
</form>
</div>
{% endif %}
</div>
</header>
<main class="container">
{% if messages %}
<div class="messages" aria-live="polite">
{# for 逐项遍历消息集合,每一轮把当前对象命名为 message。 #}
{% for message in messages %}
{# default 过滤器在 message.tags 为空时返回 info,防止生成空的消息样式类。 #}
<div class="message {{ message.tags|default:'info' }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>
完整文件:templates/403.html
{# templates/403.html #}
{# extends 让本页继承 base.html 的页面骨架,只覆盖标题和正文 block。 #}
{% extends "base.html" %}
{% block title %}无权访问{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>403 无权访问</h1>
<p>当前账号已经登录,但没有访问此页面所需的业务能力。请联系管理员为账号分配合适的项目角色。</p>
<a class="button secondary" href="{% url 'accounts:home' %}">返回首页</a>
</section>
{% endblock %}
完整文件:templates/accounts/login.html
{# templates/accounts/login.html #}
{% extends "base.html" %}
{% block title %}登录{% endblock %}
{% block content %}
<section class="panel narrow login-panel">
<h1>登录 devopsX</h1>
{% if form.non_field_errors %}<div class="form-errors">
{# form.non_field_errors 渲染整张表单级别的错误,例如用户名与密码组合无效。 #}
{{ form.non_field_errors }}
</div>{% endif %}
<form method="post">
{% csrf_token %}
{% for field in form %}
<div class="field">
{{ field.label_tag }}
{{ field }}
{{ field.errors }}
</div>
{% endfor %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button class="button" type="submit">登录</button>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/password_change_form.html
PasswordChangeView 会把当前登录用户和 POST 数据交给 Django 自带表单。模板只负责显示字段、提交 CSRF token 和提供取消入口;它不读取明文旧密码,也不自行计算密码哈希。
{# templates/accounts/password_change_form.html #}
{% extends "base.html" %}
{% block title %}修改密码{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>修改密码</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="form-actions">
<button class="button" type="submit">保存新密码</button>
<a class="button secondary" href="{% url 'accounts:home' %}">取消</a>
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/password_change_done.html
密码修改成功后,Django 会更新当前 Session 使用的认证哈希,所以当前浏览器不会被意外登出;这个结果页只展示成功状态并返回首页。
{# templates/accounts/password_change_done.html #}
{% extends "base.html" %}
{% block title %}密码已修改{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>密码已修改</h1>
<p>新密码已经生效,当前登录会话仍然保持。</p>
<a class="button" href="{% url 'accounts:home' %}">返回首页</a>
</section>
{% endblock %}
完整文件:templates/accounts/home.html
{# templates/accounts/home.html #}
{% extends "base.html" %}
{% load policy_tags %}
{% block title %}管理首页{% endblock %}
{% block content %}
{% policy_can "accounts.user.view" as can_view_users %}
{% policy_can "accounts.department.view" as can_view_departments %}
{% policy_can "accounts.role.view" as can_view_roles %}
{% policy_can "accounts.capability.view" as can_view_capabilities %}
<div class="page-header">
<div><h1>用户与访问控制</h1><p>登录与会话沿用 Django,业务授权由项目自有角色和能力统一判断。</p></div>
</div>
<div class="card-grid">
{% if can_view_users %}<a class="card" href="{% url 'accounts:user_list' %}"><strong>用户管理</strong><span>查看用户、状态和角色</span></a>{% endif %}
{% if can_view_departments %}<a class="card" href="{% url 'accounts:department_list' %}"><strong>部门管理</strong><span>查看和维护部门层级</span></a>{% endif %}
{% if can_view_roles %}<a class="card" href="{% url 'accounts:role_list' %}"><strong>角色管理</strong><span>使用项目自有角色组合能力</span></a>{% endif %}
{% if can_view_capabilities %}<a class="card" href="{% url 'accounts:permission_list' %}"><strong>能力目录</strong><span>只读查询稳定的业务能力编码</span></a>{% endif %}
</div>
{% endblock %}
完整文件:templates/accounts/user_list.html
{# templates/accounts/user_list.html #}
{% extends "base.html" %}
{% load policy_tags %}
{% block title %}用户管理{% endblock %}
{% block content %}
{% policy_can "accounts.user.create" as can_create_user %}
{% policy_can "accounts.user.change" as can_change_user %}
<div class="page-header">
<div><h1>用户管理</h1><p>按关键词、部门和状态筛选用户。</p></div>
{% if can_create_user %}<a class="button" href="{% url 'accounts:user_create' %}">新建用户</a>{% endif %}
</div>
<form method="get" class="filter-bar">
<label>关键词<input type="search" name="q" value="{{ keyword }}" placeholder="用户名、姓名、工号"></label>
<label>部门
<select name="department">
<option value="">全部部门</option>
{% for department in departments %}
<option value="{{ department.pk }}" {% if department.pk|stringformat:"s" == selected_department %}selected{% endif %}>{{ department }}</option>
{% endfor %}
</select>
</label>
<label>状态
<select name="status">
<option value="">全部状态</option>
<option value="active" {% if selected_status == "active" %}selected{% endif %}>启用</option>
<option value="inactive" {% if selected_status == "inactive" %}selected{% endif %}>停用</option>
</select>
</label>
<button class="button" type="submit">筛选</button>
<a class="button secondary" href="{% url 'accounts:user_list' %}">重置</a>
</form>
<div class="table-wrap">
<table>
<thead><tr><th>用户名</th><th>姓名</th><th>工号</th><th>部门</th><th>角色</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{% for user_obj in page_obj %}
<tr>
<td><a href="{% url 'accounts:user_detail' user_obj.pk %}">{{ user_obj.username }}</a></td>
<td>{{ user_obj.display_name|default:"-" }}</td>
<td>{{ user_obj.employee_number|default:"-" }}</td>
<td>{{ user_obj.department|default:"-" }}</td>
<td>{% for assignment in user_obj.user_roles.all %}{{ assignment.role.name }}{% if not forloop.last %}、{% endif %}{% empty %}-{% endfor %}</td>
<td>{% if user_obj.is_active %}<span class="badge success">启用</span>{% else %}<span class="badge muted">停用</span>{% endif %}</td>
<td><a href="{% url 'accounts:user_detail' user_obj.pk %}">详情</a>{% if can_change_user %} · <a href="{% url 'accounts:user_update' user_obj.pk %}">编辑</a>{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="7" class="empty">没有符合条件的用户。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<nav class="pagination" aria-label="分页">
{% if page_obj.has_previous %}<a href="?page={{ page_obj.previous_page_number }}&q={{ keyword|urlencode }}&department={{ selected_department }}&status={{ selected_status }}">上一页</a>{% endif %}
<span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span>
{% if page_obj.has_next %}<a href="?page={{ page_obj.next_page_number }}&q={{ keyword|urlencode }}&department={{ selected_department }}&status={{ selected_status }}">下一页</a>{% endif %}
</nav>
{% endif %}
{% endblock %}
完整文件:templates/accounts/user_detail.html
{# templates/accounts/user_detail.html #}
{% extends "base.html" %}
{% load policy_tags %}
{% block title %}用户详情{% endblock %}
{% block content %}
{% policy_can "accounts.user.change" as can_change_user %}
{% policy_can "accounts.user.assign_role" as can_assign_role %}
{% policy_can "accounts.user.delete" as can_delete_user %}
{% policy_can "accounts.user.status" as can_change_status %}
<div class="page-header">
<div><h1>{{ user_obj }}</h1><p>用户名:{{ user_obj.username }}</p></div>
<div class="button-row">
{% if can_change_user %}<a class="button secondary" href="{% url 'accounts:user_update' user_obj.pk %}">编辑资料</a>{% endif %}
{% if can_assign_role and user_obj.pk != user.pk %}<a class="button secondary" href="{% url 'accounts:user_access' user_obj.pk %}">分配角色</a>{% endif %}
{% if can_delete_user and user_obj.pk != user.pk %}<a class="button danger" href="{% url 'accounts:user_delete' user_obj.pk %}">删除</a>{% endif %}
</div>
</div>
<div class="detail-grid panel">
<div><span>姓名</span><strong>{{ user_obj.display_name|default:"-" }}</strong></div>
<div><span>工号</span><strong>{{ user_obj.employee_number|default:"-" }}</strong></div>
<div><span>邮箱</span><strong>{{ user_obj.email|default:"-" }}</strong></div>
<div><span>手机号</span><strong>{{ user_obj.mobile|default:"-" }}</strong></div>
<div><span>部门</span><strong>{{ user_obj.department|default:"-" }}</strong></div>
<div><span>状态</span><strong>{% if user_obj.is_active %}启用{% else %}停用{% endif %}</strong></div>
<div><span>角色</span><strong>{% for assignment in user_obj.user_roles.all %}{{ assignment.role.name }}{% if not forloop.last %}、{% endif %}{% empty %}-{% endfor %}</strong></div>
</div>
{% if can_change_status and user_obj.pk != user.pk %}
<form method="post" action="{% url 'accounts:user_status' user_obj.pk %}" class="top-space">
{% csrf_token %}
<button class="button {% if user_obj.is_active %}danger{% endif %}" type="submit">{% if user_obj.is_active %}停用用户{% else %}启用用户{% endif %}</button>
</form>
{% endif %}
<p class="top-space"><a href="{% url 'accounts:user_list' %}">返回用户列表</a></p>
{% endblock %}
完整文件:templates/accounts/user_form.html
{# templates/accounts/user_form.html #}
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<section class="panel form-panel">
<h1>{{ title }}</h1>
{% if user_obj %}<p>本页面只修改业务资料,不修改密码、角色或超级用户状态。</p>{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="form-actions">
<button class="button" type="submit">保存</button>
{% if user_obj %}<a class="button secondary" href="{% url 'accounts:user_detail' user_obj.pk %}">取消</a>{% else %}<a class="button secondary" href="{% url 'accounts:user_list' %}">取消</a>{% endif %}
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/user_access_form.html
{# templates/accounts/user_access_form.html #}
{% extends "base.html" %}
{% block title %}分配角色{% endblock %}
{% block content %}
<section class="panel form-panel">
<h1>分配角色</h1>
<p>用户:{{ user_obj }}。用户只获得角色,不提供直接能力分配;可选角色的有效能力必须是当前操作人的子集。</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="form-actions">
<button class="button" type="submit">保存分配</button>
<a class="button secondary" href="{% url 'accounts:user_detail' user_obj.pk %}">取消</a>
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/user_confirm_delete.html
{# templates/accounts/user_confirm_delete.html #}
{% extends "base.html" %}
{% block title %}删除用户{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>确认删除用户</h1>
<p>确定删除“{{ user_obj }}”吗?此操作不能撤销。</p>
<form method="post">
{% csrf_token %}
<div class="form-actions">
<button class="button danger" type="submit">确认删除</button>
<a class="button secondary" href="{% url 'accounts:user_detail' user_obj.pk %}">取消</a>
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/department_list.html
{# templates/accounts/department_list.html #}
{% extends "base.html" %}
{% load policy_tags %}
{% block title %}部门管理{% endblock %}
{% block content %}
{% policy_can "accounts.department.manage" as can_manage_departments %}
<div class="page-header">
<div><h1>部门管理</h1><p>部门存在子部门或用户时不能删除。</p></div>
{% if can_manage_departments %}<a class="button" href="{% url 'accounts:department_create' %}">新建部门</a>{% endif %}
</div>
<div class="table-wrap">
<table>
<thead><tr><th>编码</th><th>名称</th><th>上级部门</th><th>状态</th><th>排序</th><th>子部门</th><th>用户</th><th>操作</th></tr></thead>
<tbody>
{% for department in departments %}
<tr>
<td>{{ department.code }}</td>
<td>{{ department.name }}</td>
<td>{{ department.parent|default:"-" }}</td>
<td>{% if department.is_active %}<span class="badge success">启用</span>{% else %}<span class="badge muted">停用</span>{% endif %}</td>
<td>{{ department.sort_order }}</td>
<td>{{ department.child_count }}</td>
<td>{{ department.user_count }}</td>
<td>
{% if can_manage_departments %}<a href="{% url 'accounts:department_update' department.pk %}">编辑</a> · <a href="{% url 'accounts:department_delete' department.pk %}">删除</a>{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="8" class="empty">尚未创建部门。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
完整文件:templates/accounts/department_form.html
{# templates/accounts/department_form.html #}
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<section class="panel form-panel">
<h1>{{ title }}</h1>
<p>上级部门不能选择当前部门或当前部门的后代。</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="form-actions">
<button class="button" type="submit">保存</button>
<a class="button secondary" href="{% url 'accounts:department_list' %}">取消</a>
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/department_confirm_delete.html
{# templates/accounts/department_confirm_delete.html #}
{% extends "base.html" %}
{% block title %}删除部门{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>确认删除部门</h1>
<p>确定删除“{{ department }}”吗?存在子部门或用户时系统会拒绝删除。</p>
<form method="post">
{% csrf_token %}
<div class="form-actions">
<button class="button danger" type="submit">确认删除</button>
<a class="button secondary" href="{% url 'accounts:department_list' %}">取消</a>
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/role_list.html
{# templates/accounts/role_list.html #}
{% extends "base.html" %}
{% load policy_tags %}
{% block title %}角色管理{% endblock %}
{% block content %}
{% policy_can "accounts.role.manage" as can_manage_roles %}
<div class="page-header">
<div><h1>角色管理</h1><p>角色由项目自有模型维护;已分配用户的角色和系统内置角色不能删除。</p></div>
{% if can_manage_roles %}<a class="button" href="{% url 'accounts:role_create' %}">新建角色</a>{% endif %}
</div>
<div class="table-wrap">
<table>
<thead><tr><th>角色编码</th><th>角色名称</th><th>状态</th><th>用户数</th><th>能力数</th><th>操作</th></tr></thead>
<tbody>
{% for role in roles %}
<tr>
<td><code>{{ role.key }}</code></td>
<td>{{ role.name }}{% if role.is_system %} <span class="badge muted">系统</span>{% endif %}</td>
<td>{% if role.is_active %}<span class="badge success">启用</span>{% else %}<span class="badge muted">停用</span>{% endif %}</td>
<td>{{ role.user_count }}</td>
<td>{{ role.role_capabilities.count }}</td>
<td>
{% if role.is_system %}
<span class="muted">由初始化命令维护</span>
{% elif can_manage_roles %}
<a href="{% url 'accounts:role_update' role.pk %}">编辑</a> · <a href="{% url 'accounts:role_delete' role.pk %}">删除</a>
{% else %}
<span class="muted">-</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="empty">尚未创建角色。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
完整文件:templates/accounts/role_form.html
{# templates/accounts/role_form.html #}
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<section class="panel form-panel">
<h1>{{ title }}</h1>
<p>能力来自项目固定目录。非超级用户只能组合自己已经具备的有效能力。</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="form-actions">
<button class="button" type="submit">保存</button>
<a class="button secondary" href="{% url 'accounts:role_list' %}">取消</a>
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/role_confirm_delete.html
{# templates/accounts/role_confirm_delete.html #}
{% extends "base.html" %}
{% block title %}删除角色{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>确认删除角色</h1>
<p>确定删除“{{ role.name }}”吗?已有用户的角色不能删除。</p>
<form method="post">
{% csrf_token %}
<div class="form-actions">
<button class="button danger" type="submit">确认删除</button>
<a class="button secondary" href="{% url 'accounts:role_list' %}">取消</a>
</div>
</form>
</section>
{% endblock %}
完整文件:templates/accounts/permission_list.html
{# templates/accounts/permission_list.html #}
{% extends "base.html" %}
{% block title %}能力目录{% endblock %}
{% block content %}
<div class="page-header">
<div><h1>能力目录</h1><p>只读展示项目自有稳定能力,不在运行时动态创建、编辑或删除。</p></div>
</div>
<form method="get" class="filter-bar">
<label>命名空间
<select name="app_label">
<option value="">全部命名空间</option>
{% for label in app_labels %}<option value="{{ label }}" {% if label == selected_app_label %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</label>
<label>关键词<input type="search" name="q" value="{{ keyword }}" placeholder="名称、能力编码、说明"></label>
<button class="button" type="submit">筛选</button>
<a class="button secondary" href="{% url 'accounts:permission_list' %}">重置</a>
</form>
<div class="table-wrap">
<table>
<thead><tr><th>能力编码</th><th>能力名称</th><th>说明</th><th>状态</th><th>类型</th></tr></thead>
<tbody>
{% for capability in capabilities %}
<tr><td><code>{{ capability.key }}</code></td><td>{{ capability.name }}</td><td>{{ capability.description|default:"-" }}</td><td>{% if capability.is_active %}<span class="badge success">启用</span>{% else %}<span class="badge muted">停用</span>{% endif %}</td><td>{% if capability.is_system %}系统内置{% else %}自定义{% endif %}</td></tr>
{% empty %}
<tr><td colspan="5" class="empty">没有符合条件的能力。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
3. 执行顺序、预期输出与常见错误
以下命令都在包含 manage.py 的外层项目根目录执行。先保存全部源码,再迁移,再同步目录;不要在迁移之前启动管理页面。
| 命令 | 工作目录 | 目的 | 预期输出 | 常见错误 |
|---|---|---|---|---|
python manage.py check | 含 manage.py 的项目根目录 | 检查导入、模型、URL 与模板配置 | System check identified no issues | 残留第 3 篇导入、路由名拼错、templatetags 包缺少空的 __init__.py。 |
python manage.py makemigrations --check --dry-run | 项目根目录 | 确认 models.py 与 0001~0003 状态一致 | No changes detected | 手工代码与迁移字段不一致;不要生成一个意外的 0004 来掩盖复制错误。 |
python manage.py showmigrations accounts | 项目根目录 | 查看 accounts 迁移状态 | 迁移前 0002/0003 为 [ ],执行后为 [X] | App 未安装、数据库连接失败、运行目录错误。 |
python manage.py migrate | 项目根目录 | 创建 RBAC 表并执行历史转换 | Applying accounts.0002_custom_rbac... OK 与 0003... OK | 历史数据违反唯一约束、数据库账号无 DDL 权限;先备份并修正根因。 |
python manage.py bootstrap_rbac | 项目根目录 | 校验并精确同步能力和四个系统角色 | “能力:新建 X、更新 X、停用 X;角色:……” | 保留 key 被非系统行占用时命令主动失败;不要强行改成静默接管。 |
python manage.py bootstrap_rbac(再次执行) | 项目根目录 | 验证幂等性和无漂移状态 | 各项新建、更新、停用、关系增删通常都是 0 | 持续出现更新说明有人或其他任务在命令之间改写系统目录。 |
python manage.py runserver | 项目根目录 | 启动本地验收页面 | 开发服务器监听本地地址,无系统检查错误 | 端口占用、环境变量缺失、数据库未迁移。 |
3.1 依次执行
python manage.py check
python manage.py makemigrations --check --dry-run
python manage.py showmigrations accounts
python manage.py migrate
python manage.py bootstrap_rbac
python manage.py bootstrap_rbac
python manage.py showmigrations accounts
python manage.py runserver
4. 验收清单
4.1 桌面端
4.2 移动端
4.3 安全与并发
5. 全局能力与对象授权的交集
5.1 Automation 全局能力目录
下面直接展示最终 accounts/capabilities.py 中 Automation 常量和完整三元组目录,不再先写一种数据形状、集成时再改成另一种。每个三元组依次是稳定能力键、中文名称和用途说明;for key, name, description in CAPABILITY_CATALOG 会按位置把三个值解包给 bootstrap。能力键是代码与数据库共同使用的契约,中文文案可以调整,键不能随页面文字变化。
# accounts/capabilities.py:最终能力常量与三元组目录
# Automation 的全局能力。definition/inventory/credential 的具体对象仍由
# automation.services.authorization 中的 AccessGrant 决定。
AUTOMATION_CREDENTIAL_VIEW = "automation.credential.view"
AUTOMATION_CREDENTIAL_CREATE = "automation.credential.create"
AUTOMATION_CREDENTIAL_CHANGE = "automation.credential.change"
AUTOMATION_CREDENTIAL_DELETE = "automation.credential.delete"
AUTOMATION_CREDENTIAL_USE = "automation.credential.use"
AUTOMATION_CREDENTIAL_ROTATE = "automation.credential.rotate"
AUTOMATION_INVENTORY_VIEW = "automation.inventory.view"
AUTOMATION_INVENTORY_CREATE = "automation.inventory.create"
AUTOMATION_INVENTORY_CHANGE = "automation.inventory.change"
AUTOMATION_INVENTORY_DELETE = "automation.inventory.delete"
AUTOMATION_INVENTORY_USE = "automation.inventory.use"
AUTOMATION_MANUAL_TARGET_VIEW = "automation.manualtarget.view"
AUTOMATION_MANUAL_TARGET_CREATE = "automation.manualtarget.create"
AUTOMATION_MANUAL_TARGET_CHANGE = "automation.manualtarget.change"
AUTOMATION_MANUAL_TARGET_DELETE = "automation.manualtarget.delete"
AUTOMATION_DEFINITION_VIEW = "automation.definition.view"
AUTOMATION_DEFINITION_CREATE = "automation.definition.create"
AUTOMATION_DEFINITION_CHANGE = "automation.definition.change"
AUTOMATION_DEFINITION_DELETE = "automation.definition.delete"
AUTOMATION_DEFINITION_EXECUTE = "automation.definition.execute"
AUTOMATION_DEFINITION_PUBLISH = "automation.definition.publish"
AUTOMATION_JOB_VIEW = "automation.job.view"
AUTOMATION_JOB_CANCEL = "automation.job.cancel"
AUTOMATION_JOB_OUTPUT_VIEW = "automation.job.output_view"
AUTOMATION_RESTRICTED_OUTPUT_VIEW = "automation.job.restricted_output_view"
AUTOMATION_APPROVAL_VIEW = "automation.approval.view"
AUTOMATION_APPROVAL_APPROVE = "automation.approval.approve"
AUTOMATION_WORKER_VIEW = "automation.worker.view"
AUTOMATION_WORKER_MANAGE = "automation.worker.manage"
AUTOMATION_JOB_ATTEMPT_VIEW = "automation.job_attempt.view"
AUTOMATION_JOB_TARGET_VIEW = "automation.job_target.view"
AUTOMATION_AUDIT_EVENT_VIEW = "automation.audit_event.view"
CAPABILITY_CATALOG = (
(DASHBOARD_VIEW, "查看平台首页", "访问平台首页并查看当前用户有权使用的模块。"),
(USER_VIEW, "查看用户", "查看用户列表和用户详情。"),
(USER_CREATE, "创建用户", "创建本地用户账号。"),
(USER_CHANGE, "编辑用户", "编辑用户资料。"),
(USER_DELETE, "删除用户", "删除用户账号。"),
(USER_STATUS, "管理用户状态", "启用或停用用户账号。"),
(USER_ASSIGN_ROLE, "分配用户角色", "为用户绑定或解除项目角色。"),
(ROLE_VIEW, "查看角色", "查看角色及其能力。"),
(ROLE_MANAGE, "管理角色", "创建、编辑和停用角色及角色能力。"),
(CAPABILITY_VIEW, "查看能力目录", "查看系统登记的业务能力。"),
(CMDB_CLOUD_PROVIDER_VIEW, "查看云厂商", "查看 CMDB 中登记的云厂商。"),
(CMDB_CLOUD_ACCOUNT_VIEW, "查看云账号", "查看云账号及其同步结果。"),
(CMDB_CLOUD_ACCOUNT_CREATE, "创建云账号", "新增云账号。"),
(CMDB_CLOUD_ACCOUNT_CHANGE, "编辑云账号", "修改云账号配置。"),
(CMDB_CLOUD_ACCOUNT_DELETE, "删除云账号", "删除云账号。"),
(CMDB_CLOUD_ACCOUNT_SYNC, "同步云账号", "启动云账号资源同步。"),
(CMDB_CLOUD_ACCOUNT_IMPORT, "导入云账号", "通过文件导入云账号。"),
(CMDB_COMPUTE_INSTANCE_VIEW, "查看计算实例", "查看 CMDB 计算实例。"),
(CMDB_COMPUTE_INSTANCE_CHANGE, "编辑计算实例", "修改计算实例维护信息。"),
(CMDB_COMPUTE_INSTANCE_DELETE, "删除计算实例", "删除计算实例记录。"),
(CMDB_COMPUTE_INSTANCE_EXPORT, "导出计算实例", "导出计算实例数据。"),
(CMDB_COMPUTE_INSTANCE_RETIRE, "退役计算实例", "将计算实例标记为退役。"),
(CMDB_SYNC_RUN_VIEW, "查看同步历史", "查看 CMDB 同步运行记录。"),
(AUTOMATION_CREDENTIAL_VIEW, "查看自动化凭据", "查看凭据的非秘密资料。"),
(AUTOMATION_CREDENTIAL_CREATE, "创建自动化凭据", "创建自动化凭据。"),
(AUTOMATION_CREDENTIAL_CHANGE, "编辑自动化凭据", "编辑凭据的管理信息。"),
(AUTOMATION_CREDENTIAL_DELETE, "删除自动化凭据", "删除自动化凭据。"),
(AUTOMATION_CREDENTIAL_USE, "使用自动化凭据", "在允许的目标上使用凭据。"),
(AUTOMATION_CREDENTIAL_ROTATE, "轮换自动化凭据", "替换凭据版本。"),
(AUTOMATION_INVENTORY_VIEW, "查看目标清单", "查看自动化目标清单。"),
(AUTOMATION_INVENTORY_CREATE, "创建目标清单", "创建目标清单。"),
(AUTOMATION_INVENTORY_CHANGE, "编辑目标清单", "编辑目标清单。"),
(AUTOMATION_INVENTORY_DELETE, "删除目标清单", "删除目标清单。"),
(AUTOMATION_INVENTORY_USE, "使用目标清单", "在目标清单上启动自动化任务。"),
(AUTOMATION_MANUAL_TARGET_VIEW, "查看手工目标", "查看清单中的手工目标。"),
(AUTOMATION_MANUAL_TARGET_CREATE, "创建手工目标", "向清单添加手工目标。"),
(AUTOMATION_MANUAL_TARGET_CHANGE, "编辑手工目标", "修改手工目标。"),
(AUTOMATION_MANUAL_TARGET_DELETE, "删除手工目标", "删除手工目标。"),
(AUTOMATION_DEFINITION_VIEW, "查看执行定义", "查看自动化执行定义。"),
(AUTOMATION_DEFINITION_CREATE, "创建执行定义", "创建自动化执行定义。"),
(AUTOMATION_DEFINITION_CHANGE, "编辑执行定义", "修改执行定义。"),
(AUTOMATION_DEFINITION_DELETE, "删除执行定义", "删除执行定义。"),
(AUTOMATION_DEFINITION_EXECUTE, "执行自动化定义", "请求执行自动化定义。"),
(AUTOMATION_DEFINITION_PUBLISH, "发布执行定义", "发布执行定义修订。"),
(AUTOMATION_JOB_VIEW, "查看自动化任务", "查看自动化任务及其状态。"),
(AUTOMATION_JOB_CANCEL, "取消自动化任务", "请求取消尚未结束的任务。"),
(AUTOMATION_JOB_OUTPUT_VIEW, "查看任务输出", "查看任务输出。"),
(AUTOMATION_RESTRICTED_OUTPUT_VIEW, "查看受限输出", "查看被标记为受限的任务输出。"),
(AUTOMATION_APPROVAL_VIEW, "查看审批队列", "查看待审批自动化任务。"),
(AUTOMATION_APPROVAL_APPROVE, "审批自动化任务", "批准或拒绝自动化任务。"),
(AUTOMATION_WORKER_VIEW, "查看 Worker", "查看 Worker 心跳和运行状态。"),
(AUTOMATION_WORKER_MANAGE, "管理 Worker", "执行 Worker 管理动作。"),
(AUTOMATION_JOB_ATTEMPT_VIEW, "查看任务尝试", "查看任务执行尝试记录。"),
(AUTOMATION_JOB_TARGET_VIEW, "查看任务目标", "查看任务目标记录。"),
(AUTOMATION_AUDIT_EVENT_VIEW, "查看自动化审计", "查看自动化审计事件。"),
)
接着展示同一最终文件中的完整角色目录和派生键集合。item[0] 读取能力三元组的第一个元素;生成式只挑出以 automation. 开头的稳定键,因此 Automation 管理员不会误得账号或 CMDB 能力。frozenset 生成不可原地增删的键集合,适合做目录成员判断。
# accounts/capabilities.py:最终角色目录与派生键集合
ROLE_CATALOG = (
{
"key": "platform_admin",
"name": "平台管理员",
"description": "拥有已登记的全部业务能力。",
"capabilities": tuple(item[0] for item in CAPABILITY_CATALOG),
},
{
"key": "cmdb_viewer",
"name": "CMDB 查看员",
"description": "查看 CMDB 厂商、云账号、计算实例和同步历史。",
"capabilities": (
CMDB_CLOUD_PROVIDER_VIEW,
CMDB_CLOUD_ACCOUNT_VIEW,
CMDB_COMPUTE_INSTANCE_VIEW,
CMDB_SYNC_RUN_VIEW,
),
},
{
"key": "automation_operator",
"name": "自动化执行员",
"description": "查看自动化资源并执行有对象授权的任务。",
"capabilities": (
AUTOMATION_CREDENTIAL_VIEW,
AUTOMATION_INVENTORY_VIEW,
AUTOMATION_INVENTORY_USE,
AUTOMATION_DEFINITION_VIEW,
AUTOMATION_DEFINITION_EXECUTE,
AUTOMATION_JOB_VIEW,
AUTOMATION_JOB_OUTPUT_VIEW,
),
},
{
"key": "automation_admin",
"name": "自动化管理员",
"description": "管理自动化配置、任务审批和 Worker。",
"capabilities": tuple(
item[0]
for item in CAPABILITY_CATALOG
if item[0].startswith("automation.")
),
},
)
CAPABILITY_KEYS = frozenset(item[0] for item in CAPABILITY_CATALOG)
ROLE_KEYS = frozenset(item["key"] for item in ROLE_CATALOG)
最终 bootstrap 不再维护另一套 validate_catalog() 字典校验逻辑,而是用 for key, name, description in CAPABILITY_CATALOG 直接解包这份三元组目录,再以 key 调用 update_or_create() 幂等同步。业务代码只调用 Policy;不能在模板里判断角色名称,因为名称可改,模板也不是后端安全边界。
5.2 交集规则
| 全局能力 | 对象动作 | 结果 | 原因 |
|---|---|---|---|
| 无 | 无 | 拒绝 | 两层都不满足 |
| 有 | 无 | 拒绝 | 只能知道这类动作,不能触达该对象 |
| 无 | 有 | 拒绝 | 对象授权不能制造全局能力 |
| 有 | 有 | 允许 | 普通用户满足交集 |
| 激活超级用户 | 任意 | 内部旁路 | 保留运维恢复入口 |
CMDB 页面继续使用全局能力控制模块入口和资源类型动作;Automation 对执行定义、目标清单、凭据增加对象授权。不要把 AccessGrant 写成第二套全局角色系统,也不要让它绕过 accounts.policy。
6. AccessGrant:User、Role 与历史 Group 三类主体
6.1 automation/models/access.py 最终完整文件
# automation/models/access.py
# 第三方绝对导入:settings 读取项目配置;Group 是 Django 自带用户组;models 提供 ORM 字段/模型基类。
from django.conf import settings
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.db import models
# Q 把查询条件包装成可用 |(或)、&(且)组合的对象,普通关键字参数只能自然表达“且”。
from django.db.models import Q
# 单点开头的相对导入表示“从当前 automation.models 包导入”,重命名应用包时不必改绝对包名。
from .catalog import Inventory, validate_string_list
from .credentials import Credential
from .definitions import ExecutionDefinition
# 对象授权动作白名单;集合字面量用花括号创建去重成员,`in` 可快速检查动作是否属于系统支持范围。
ALLOWED_GRANT_ACTIONS = {
"view",
"use",
"manage",
"execute",
"approve",
"cancel",
"view_output",
"mutate",
"delete",
}
def validate_grant_actions(value):
"""输入动作列表,输出无返回值;由模型字段校验调用,失败时抛出 ValidationError。"""
# 业务判断:先复用字符串列表校验,再拒绝白名单之外的授权动作。
validate_string_list(value)
unknown = sorted(set(value) - ALLOWED_GRANT_ACTIONS)
if unknown:
raise ValidationError("授权动作无效:%s" % ", ".join(unknown))
class AccessGrant(models.Model):
"""把一个用户、角色或用户组对一个自动化对象的动作授权持久化。"""
# 继承 models.Model 后,类属性会由 Django 元类转换为数据库字段,实例自动获得 save()/full_clean() 等 ORM 方法。
# 类型:用户外键;第一个参数取可替换用户模型,避免写死 auth.User;on_delete=CASCADE 表示用户删除时级联删授权。
# related_name 是从用户反查授权的属性名;verbose_name 是后台/表单标签;null=True 允许数据库 NULL,blank=True 允许表单留空。
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="automation_access_grants",
verbose_name="用户",
null=True,
blank=True,
)
# 类型:用户组外键;Group 是关联目标。on_delete=CASCADE 表示用户组删除时级联删除对应对象授权。
# related_name 定义从 Group 反查授权的 automation_access_grants 属性;verbose_name 是页面显示的“用户组”。
# null=True 允许数据库保存 NULL;blank=True 允许模型表单不选用户组,最终由“三种主体恰选一种”约束收口。
group = models.ForeignKey(
Group,
on_delete=models.CASCADE,
related_name="automation_access_grants",
verbose_name="用户组",
null=True,
blank=True,
)
# 类型:角色外键;字符串 "accounts.Role" 是延迟模型引用,应用注册完成后再解析,可避免导入环。
# on_delete=CASCADE 表示角色删除时级联删授权;related_name 是从角色反查授权的属性名;verbose_name 是页面显示名。
# null=True 允许数据库 NULL,blank=True 允许表单留空;三种主体最终仍必须恰好选择一种。
role = models.ForeignKey(
"accounts.Role",
on_delete=models.CASCADE,
related_name="automation_access_grants",
verbose_name="角色",
null=True,
blank=True,
)
# Credential 是凭据资源外键;on_delete=CASCADE 表示凭据删除时同步清除不再有意义的授权。
# related_name="access_grants" 允许 credential.access_grants 反查;verbose_name 是表单/后台显示名。
# null=True 允许数据库 NULL,blank=True 允许表单留空;三个资源字段最终必须恰好填写一个。
credential = models.ForeignKey(
Credential,
on_delete=models.CASCADE,
related_name="access_grants",
verbose_name="凭据",
null=True,
blank=True,
)
# Inventory 是目标清单外键;on_delete=CASCADE 表示清单删除时级联删除它的对象授权。
# related_name="access_grants" 提供 inventory.access_grants 反查;verbose_name="目标清单" 是人类可读字段名称。
# null=True 允许数据库为空,blank=True 允许表单不选清单;资源“三选一”约束负责最终一致性。
inventory = models.ForeignKey(
Inventory,
on_delete=models.CASCADE,
related_name="access_grants",
verbose_name="目标清单",
null=True,
blank=True,
)
# ExecutionDefinition 是执行定义外键;on_delete=CASCADE 表示定义删除后对应授权也随之删除。
# related_name="access_grants" 提供 definition.access_grants 反查;verbose_name 是页面显示的“执行定义”。
# null=True 允许数据库 NULL,blank=True 允许表单留空;约束保证它与另外两个资源字段只能选一个。
definition = models.ForeignKey(
ExecutionDefinition,
on_delete=models.CASCADE,
related_name="access_grants",
verbose_name="执行定义",
null=True,
blank=True,
)
# JSONField 保存字符串列表;位置参数 "授权动作" 是 verbose_name;default=list 让每条记录获得独立空列表,不能写共享的 []。
# validators 在 full_clean()/ModelForm 校验时调用 validate_grant_actions;数据库本身不会执行 Python validator。
actions = models.JSONField(
"授权动作",
default=list,
validators=[validate_grant_actions],
)
# 第一个位置参数 "命名空间限制" 是 verbose_name;default=list 为每条记录创建独立默认空列表。
# blank=True 允许表单空输入;validators 会调用 validate_string_list;未设置 null=True,所以数据库列仍是 NOT NULL。
namespaces = models.JSONField(
"命名空间限制",
default=list,
blank=True,
validators=[validate_string_list],
)
# DateTimeField 的位置参数是显示名;auto_now_add=True 仅在首次插入时由 Django 写当前时间,之后保存不会自动改它。
created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta:
# Meta 是模型配置内部类:ordering 给未显式 order_by() 的查询默认按 id 升序,列表顺序也可写 "-id" 表示降序。
ordering = ["id"]
# constraints 会生成数据库约束;此模型没有声明 Meta.indexes,除主键/外键自带索引外不新增显式索引。
constraints = [
# CheckConstraint.condition 接受布尔 Q 表达式,name 是数据库中稳定且唯一的约束名,迁移靠它增删约束。
models.CheckConstraint(
# 每个 Q(...) 内的关键字条件是“且”;三个 Q 用 | 连接成“或”,故用户/组/角色恰好一个非空。
condition=(
Q(user__isnull=False, group__isnull=True, role__isnull=True)
| Q(user__isnull=True, group__isnull=False, role__isnull=True)
| Q(user__isnull=True, group__isnull=True, role__isnull=False)
),
name="automation_grant_exactly_one_principal",
),
# 第二个 CheckConstraint 用相同结构保证 credential/inventory/definition 恰好一个非空。
models.CheckConstraint(
condition=(
Q(
credential__isnull=False,
inventory__isnull=True,
definition__isnull=True,
)
| Q(
credential__isnull=True,
inventory__isnull=False,
definition__isnull=True,
)
| Q(
credential__isnull=True,
inventory__isnull=True,
definition__isnull=False,
)
),
name="automation_grant_exactly_one_resource_v2",
),
]
# verbose_name 是单数显示名;verbose_name_plural 是复数显示名,中文通常可保持相同。
verbose_name = "对象授权"
verbose_name_plural = "对象授权"
def clean(self):
"""输入当前授权实例,输出无返回值;由模型/表单校验调用,主体或资源未且仅选一个时失败。"""
# super() 取得父类 models.Model 的绑定代理;调用父实现可保留 Django 自身的模型校验。
super().clean()
# 业务判断:*_id 直接读取当前实例上的外键主键,不查询关联对象;bool 转真假,int 再把真假变成 1/0 后即可计数。
principal_count = (
int(bool(self.user_id))
+ int(bool(self.group_id))
+ int(bool(self.role_id))
)
resource_count = (
int(bool(self.credential_id))
+ int(bool(self.inventory_id))
+ int(bool(self.definition_id))
)
if principal_count != 1:
raise ValidationError("授权必须且只能选择一个用户、角色或用户组。")
if resource_count != 1:
raise ValidationError("授权必须且只能选择一个资源。")
# 数据库保存:clean() 只做应用层校验并自然返回 None,不写数据库;调用者需显式执行 full_clean(),最终一致性还由数据库 CheckConstraint 兜底。
def __str__(self):
"""输入当前授权实例,输出可读的主体到资源文本;由后台与日志调用,无额外业务失败。"""
principal = self.user or self.role or self.group
resource = self.credential or self.inventory or self.definition
return "%s -> %s" % (principal, resource)
6.2 为什么模型和数据库都要检查“恰好一个”
validate_grant_actions(value) 的输入是 JSON 动作列表,输出为 None;模型 full_clean()、表单或显式校验调用它,失败时抛 ValidationError。它先调用 validate_string_list() 检查结构,再拒绝白名单外动作。若只在前端下拉框限制,脚本可以直接提交 superuser、* 等任意字符串。
AccessGrant.clean() 读取三个主体外键和三个资源外键,检查每组计数都等于一,不写数据库,成功返回 None。数据库 CheckConstraint 再守最后一道边界,因为 bulk_create()、数据迁移和数据库客户端不一定调用 clean()。一个授权同时指向用户和角色会造成审计主体不确定;一个授权同时指向定义和凭据会让动作语义不确定。
__str__() 只用于 Admin 和日志的人类可读展示,输入是实例本身,返回“主体 -> 资源”。它不能被授权逻辑解析;授权必须读外键和动作数组。
6.3 Role 主体迁移
在 automation/migrations/0005_accessgrant_role_principal.py 新增角色主体并重建主体约束。迁移依赖 accounts 的 RBAC 目录已经存在。
# automation/migrations/0005_accessgrant_role_principal.py
# Generated by Django 5.2.17 on 2026-09-22 09:43
# 迁移文件记录数据库结构历史;导入 deletion 是为了序列化 on_delete=CASCADE 的完整可重放路径。
import django.db.models.deletion
from django.conf import settings
# migrations 提供操作类/迁移基类,models 提供 ForeignKey、Q 和 CheckConstraint 的迁移态描述。
from django.db import migrations, models
class Migration(migrations.Migration):
# 继承 migrations.Migration 后,Django 会读取 dependencies 与 operations 构建迁移图并按序执行。
# 迁移目的:把对象授权主体从“用户或用户组”扩展为“用户、角色或用户组三选一”,不搬移既有授权数据。
# dependencies 的二元组是 (应用标签, 迁移名);必须先有 Role、Group 和旧 AccessGrant 结构。
# swappable_dependency 跟随 AUTH_USER_MODEL,支持项目把默认 auth.User 替换为自定义用户模型。
dependencies = [
('accounts', '0003_seed_rbac_catalog'),
('auth', '0012_alter_user_first_name_max_length'),
('automation', '0004_workerheartbeat'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# operations 按列表顺序执行;先删旧“两选一”约束,再加可空字段和新“三选一”规则,避免两个约束并存时拒绝 role-only 授权。
operations = [
# RemoveConstraint 用迁移态模型名(小写)和稳定数据库约束名删除旧规则。
migrations.RemoveConstraint(
model_name='accessgrant',
name='automation_grant_exactly_one_principal',
),
# AddField 的 model_name/name 定位字段;field 是完整历史定义,不会导入当前 models.py,保证未来仍可重放。
migrations.AddField(
model_name='accessgrant',
name='role',
# blank=True 控制表单/模型校验可空,null=True 控制数据库 NULL;to='accounts.role' 是迁移态关系目标。
# on_delete=CASCADE 随角色删除授权;related_name 是角色反查名;verbose_name 是显示标签。
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='automation_access_grants', to='accounts.role', verbose_name='角色'),
),
# AddConstraint 把三选一规则交给数据库;condition 是 Q 条件树,name 是以后删除/替换该约束的标识。
# 这里是 makemigrations 序列化形式:每个 ('字段__lookup', 值) 是条件,_connector='OR' 连接三个内层 Q。
migrations.AddConstraint(
model_name='accessgrant',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('group__isnull', True), ('role__isnull', True), ('user__isnull', False)), models.Q(('group__isnull', False), ('role__isnull', True), ('user__isnull', True)), models.Q(('group__isnull', True), ('role__isnull', False), ('user__isnull', True)), _connector='OR'), name='automation_grant_exactly_one_principal'),
),
]
不能先增加新约束再移除旧约束:旧约束只认识 User/Group,会拒绝合法 Role 行。迁移顺序必须先移除旧约束、增加列、再建立三选一约束。
6.4 历史 Group 兼容不是继续建设 Group
新授权优先写入 role。历史 Group 仍是可读主体,保证迁移窗口内既有授权不突然失效;初始化命令还把旧 Group 的全局 Permission 和对象授权复制到一个 legacy-group-<pk> 兼容 Role。下面是完整的对象授权复制函数和关键调用:
# accounts/management/commands/bootstrap_rbac.py:兼容迁移片段
def _sync_legacy_groups(self, capabilities):
# 输入:按能力键索引的 Capability 字典;输出:“兼容角色数量、对象授权副本数量”二元组;调用者:handle();失败:旧组、角色或授权查询/写入失败时事务整体回滚。
# 数据库读取:预取每个 Django Group 的旧权限和成员,并逐组检查是否存在历史对象授权。
count = 0
object_grant_count = 0
# prefetch_related 对多值关系另发批量查询并在 Python 关联;不会像 select_related 那样做单条 JOIN。
for group in Group.objects.prefetch_related("permissions", "user_set"):
capability_keys = self._mapped_permission_keys(group.permissions.all())
# exists() 生成只问“是否至少一行”的短查询,不把全部 AccessGrant 实例加载进内存。
has_object_grants = AccessGrant.objects.filter(group=group).exists()
role_key = "legacy-group-%s" % group.pk
role = Role.objects.filter(key=role_key).first()
# 业务判断:没有全局能力、没有对象授权且从未创建兼容角色时,无需制造空角色。
if not capability_keys and not has_object_grants and role is None:
continue
# 数据库保存:幂等更新兼容角色、能力关系、对象授权副本和成员关系,使重复执行得到同一结果。
role, _ = Role.objects.update_or_create(
key=role_key,
defaults={
"name": "历史用户组 %s:%s" % (group.pk, group.name[:70]),
"description": "由旧 Django 用户组自动回填,仅用于迁移兼容。",
"is_system": True,
"is_active": bool(capability_keys or has_object_grants),
},
)
self._replace_role_capabilities(role, capability_keys, capabilities)
object_grant_count += self._replace_role_access_grants(group, role)
# values_list("pk", flat=True) 让数据库只返回一列主键;set 立即求值并去重,随后用于 __in 过滤。
member_ids = set(group.user_set.values_list("pk", flat=True))
UserRole.objects.filter(role=role).exclude(user_id__in=member_ids).delete()
# bulk_create 用一条/少量 SQL 批量插入;它绕过每个实例的 save()/full_clean() 与 save 信号,故输入须先保证合法。
UserRole.objects.bulk_create(
[
UserRole(user_id=user_id, role=role)
for user_id in member_ids
if not UserRole.objects.filter(
user_id=user_id,
role=role,
).exists()
]
)
count += 1
# 返回:把实际处理的兼容角色数和复制的对象授权数交给 handle() 输出统计。
return count, object_grant_count
def _replace_role_access_grants(self, group, role):
# 输入:旧 Django Group 与目标兼容 Role;输出:复制的对象授权数量;调用者:_sync_legacy_groups();失败:读取、删除或批量创建失败时由外层事务整体回滚。
# 数据库读取:只读取 AccessGrant 的资源外键、动作和命名空间,不复制主键或审计时间。
# list(...) 立即执行惰性 QuerySet,values() 每行给字典;这是事务内供“删旧后重建”使用的源数据快照。
source_grants = list(
AccessGrant.objects.filter(group=group).values(
"credential_id",
"inventory_id",
"definition_id",
"actions",
"namespaces",
)
)
# 数据库保存:先清空目标角色旧副本,再按本次源快照批量重建,避免重复运行累积重复授权。
AccessGrant.objects.filter(role=role).delete()
AccessGrant.objects.bulk_create(
[
# **values 把每个字典展开为关键字参数,例如 credential_id=...;不会复制源记录主键。
AccessGrant(role=role, **values)
for values in source_grants
]
)
# 返回:源快照长度就是本次建立的授权副本数量。
return len(source_grants)
_sync_legacy_groups() 输入能力映射,返回兼容 Group 数和复制授权数;由 bootstrap_rbac 的事务入口调用。它读取旧 Group 的权限、成员和对象授权,创建或更新兼容 Role、替换角色能力、同步成员、复制对象授权。任何一步失败都会回滚整个命令。_replace_role_access_grants() 输入旧 Group 和目标 Role,返回复制条数;它先读取源授权,再替换目标 Role 的副本。危险捷径是直接把旧授权改成 Role 并清空 Group:回滚与双读观察期会丢失原始兼容数据。
7. 对象授权服务与 404/403 边界
7.1 automation/services/authorization.py 最终完整文件
# automation/services/authorization.py
# Q 是可组合查询条件对象,可用 | 表示 SQL OR;普通 filter(a=..., b=...) 默认是 AND。
from django.db.models import Q
# accounts.* 是项目级绝对导入;这里复用全局 Capability 常量与策略函数。
from accounts.capabilities import (
AUTOMATION_CREDENTIAL_USE,
AUTOMATION_DEFINITION_EXECUTE,
AUTOMATION_INVENTORY_USE,
)
from accounts.policy import has_capability
# .. 表示从 automation.services 的父包 automation 导入,避免写死完整应用包路径。
from ..models import AccessGrant
class AuthorizationError(Exception):
"""自动化服务层授权失败;调用视图会把它转换为表单错误或拒绝响应。"""
# 继承 Exception 创建可单独捕获的业务错误类型;pass 表示类体无需额外成员。
pass
def _principal_query(user):
"""输入用户,输出匹配本人、有效角色和用户组的 Q 条件;由对象授权查询调用,数据库查询失败时向上抛出。"""
# user.roles 是反向关系管理器;filter() 只构造惰性 QuerySet,真正作为 role__in 子查询执行时才访问数据库。
active_roles = user.roles.filter(is_active=True)
# 三个 Q 用 | 连接:授权主体可以是本人、任一启用角色,或 user.groups.all() 给出的任一 Django 用户组。
return (
Q(user=user)
| Q(role__in=active_roles)
| Q(group__in=user.groups.all())
)
def has_object_action(user, resource_field, resource_id, action):
"""输入用户、资源字段/主键和动作,输出是否有 AccessGrant;由服务与视图调用,查询失败时向上抛出。"""
# 业务判断:超级用户绕过对象授权;普通用户必须命中本人、有效角色或用户组的对象授权。
if user.is_superuser:
return True
# 动态键把 resource_field 变成 definition_id/inventory_id/credential_id;字典稍后用 ** 展开为 filter 的关键字参数。
filters = {
"%s_id" % resource_field: resource_id,
}
# **filters 等价于写 filter(definition_id=...) 之类的静态参数;先叠加主体 Q,再只投影 actions 列。
grants = AccessGrant.objects.filter(
_principal_query(user),
**filters,
).values_list("actions", flat=True)
# values_list(..., flat=True) 返回惰性单列 QuerySet;any() 开始迭代并在首个 True 短路,但数据库游标可能已取得结果批次。
# 生成器表达式逐条判断 JSON actions 是否含目标动作,不同授权记录无需先合并成一个列表。
return any(action in actions for actions in grants)
def filter_for_object_action(user, queryset, resource_field, action):
"""输入用户、资源查询集/字段和动作,输出对象授权后的查询集;由列表与 404 查找调用,查询失败时向上抛出。"""
# 业务判断:超级用户保留原查询集;普通用户只能看到 AccessGrant 明确覆盖的对象。
if user.is_superuser:
return queryset
resource_id_field = "%s_id" % resource_field
# exclude(**{动态键: None}) 排除该资源外键为空的授权;** 在这里把运行时字段名展开成查询参数。
grants = AccessGrant.objects.filter(_principal_query(user)).exclude(
**{resource_id_field: None}
)
# values_list 返回 (资源主键, actions) 元组;列表推导式迭代时才查库,并仅保留包含 action 的资源主键。
resource_ids = [
resource_id
for resource_id, actions in grants.values_list(resource_id_field, "actions")
if action in actions
]
# 返回的仍是惰性 QuerySet;pk__in 追加 SQL IN 条件,调用者随后迭代/get/count 时才执行。
# 先过滤再交给 get_object_or_404,可让“不存在”和“存在但越权”都得到 404,避免枚举对象。
return queryset.filter(pk__in=resource_ids)
def require_object_action(user, resource_field, resource_id, action, message):
"""输入用户、资源、动作和错误消息,成功时无返回值;由服务层调用,无对象授权时抛 AuthorizationError。"""
# 这个对象级 helper 只检查 AccessGrant;单独调用不会替代登录、is_active 或全局 Capability 检查,外层必须继续守门。
if not has_object_action(user, resource_field, resource_id, action):
raise AuthorizationError(message)
def require_launch_access(user, definition, inventory):
"""输入用户、定义和清单,成功时无返回值;由启动预览调用,任一全局能力或对象授权缺失即失败。"""
# 业务判断:启动使用“双门”授权——全局 Capability 决定能否做某类事,AccessGrant 决定能否操作这个对象。
if not user.is_authenticated or not user.is_active:
raise AuthorizationError("当前用户不能执行自动化任务。")
# 业务判断:定义执行与清单使用分别要求全局门和对象门同时为真,不能互相替代。
checks = (
(
has_capability(user, AUTOMATION_DEFINITION_EXECUTE),
has_object_action(user, "definition", definition.pk, "execute"),
"当前用户无权执行该定义。",
),
(
has_capability(user, AUTOMATION_INVENTORY_USE),
has_object_action(user, "inventory", inventory.pk, "use"),
"当前用户无权使用该目标清单。",
),
)
for has_permission, has_grant, message in checks:
if not has_permission or not has_grant:
raise AuthorizationError(message)
# 数据库读取与业务判断:清单若绑定默认凭据,再额外检查凭据使用能力和该凭据的 use 授权。
credential = inventory.default_credential
if credential is not None:
if not has_capability(user, AUTOMATION_CREDENTIAL_USE):
raise AuthorizationError("当前用户无权使用该目标清单的默认凭据。")
if not has_object_action(user, "credential", credential.pk, "use"):
raise AuthorizationError("当前用户无权使用该目标清单的默认凭据。")
7.2 每个函数的输入、输出、调用者与数据动作
| 函数 | 输入与输出 | 调用者 | 读取、检查、写入、失败 |
|---|---|---|---|
_principal_query(user) | 输入本地用户,返回一个 Django Q | 本文件的查询函数 | 读取用户的激活 Role 与旧 Group;不写数据。Role 必须 is_active=True。如果去掉该条件,停用角色仍会继续授予对象访问。 |
has_object_action(...) | 输入用户、资源字段名、主键、动作;返回布尔值 | 详情页、动作视图、审批、取消、输出 | 读取匹配主体与对象的动作数组;超级用户直接返回真;普通用户任一匹配授权含动作即为真。 |
filter_for_object_action(...) | 输入用户、QuerySet、资源字段、动作;返回收窄后的 QuerySet | 列表页、详情页、任务可见范围 | 读取可访问资源主键并加 pk__in;不返回未授权对象。调用方再用 get_object_or_404(),形成防枚举的 404 边界。 |
require_object_action(...) | 无返回值 | 服务层可复用入口 | 调用布尔检查;失败抛 AuthorizationError,不写数据。 |
require_launch_access(...) | 输入用户、定义、清单;成功返回 None | build_launch_preview(),提交时会再次调用 | 检查激活状态、定义 execute 全局+对象交集、清单 use 全局+对象交集、默认凭据 use 全局+对象交集;任一失败抛安全业务错误。 |
resource_field 只能由服务端代码传入 definition、inventory、credential,不能直接接收浏览器参数。把任意字符串用于动态 ORM 字段会扩大查询面并让错误变成信息探针。
7.3 创建者立即得到 view/manage/execute
创建定义和首个不可变修订与创建者授权位于同一个事务。这样定义提交成功时,创建者必然能看到、管理和执行它;授权创建失败时,定义也不会成为无人可管理的孤儿。
# automation/services/definitions.py:create_definition() 的事务核心
# transaction.atomic 只界定该数据库连接上的事务与异常回滚;定义、首版修订和创建者授权任一步异常都会整体回滚。
# 它是否提供固定读取快照取决于数据库/隔离级别,不能泛称不可变快照。
with transaction.atomic():
definition = ExecutionDefinition(
name=name,
definition_type=definition_type,
description=description,
created_by=created_by,
)
definition.full_clean()
definition.save()
revision = _create_revision_locked(
definition=definition,
prepared=prepared,
created_by=created_by,
bundle_artifact=bundle_artifact,
)
# 保存:创建者授权(creator grant)让新定义的创建人立即拥有 view/manage/execute 对象动作;全局 Capability 仍是另一道门。
if created_by is not None:
AccessGrant.objects.create(
user=created_by,
definition=definition,
actions=["view", "manage", "execute"],
)
# 返回:调用者同时获得定义和已落库的首版修订。
return definition, revision
输入是已经规范化的定义资料、创建人和可选制品,输出是 (definition, revision);定义创建视图调用它。它读取同名约束与修订序号,检查模型和定义规范,写定义、修订和授权。不要把创建者授权放到事务提交后的异步任务:页面重定向可能先发生,创建者会短暂得到 404;异步失败还会留下永久孤儿。
7.4 列表与详情返回 404,已知对象上的动作拒绝返回 403
# automation/views.py:定义可见范围与动作边界
def _definitions_for_user(user, action="view"):
"""输入用户与对象动作,输出已按 AccessGrant 过滤的定义查询集;由定义/任务视图调用,查询失败时向上抛出。"""
# select_related 沿单值外键用 JOIN 预取;返回 QuerySet 仍惰性,后续过滤/分页/迭代时才执行 SQL。
queryset = ExecutionDefinition.objects.select_related(
"current_revision",
"created_by",
)
return filter_for_object_action(
user,
queryset,
"definition",
action,
)
@login_required
@capability_required(AUTOMATION_DEFINITION_VIEW)
def definition_list(request):
"""输入定义查看请求;输出对象授权过滤后的分页列表;调用者:URL 路由;失败:匿名用户重定向,缺全局能力时 403,数据库或渲染异常继续上抛。"""
definitions = _definitions_for_user(request.user).order_by("name")
page_obj = Paginator(definitions, 30).get_page(request.GET.get("page"))
return render(
request,
"automation/definition_list.html",
{"page_obj": page_obj},
)
@login_required
@capability_required(AUTOMATION_DEFINITION_VIEW)
def definition_detail(request, public_id):
"""输入定义公开 ID;输出详情页;调用者:URL 路由;失败:匿名用户重定向,缺全局能力时 403,不存在或未获对象 view 授权时 404。"""
# 全局 capability 装饰器先处理:匿名 302、已登录缺全局能力 403;只有通过后才执行对象过滤查询。
# 先以 AccessGrant 过滤再 get_object_or_404,使“不存在”和“存在但无对象 view”都返回 404,保留反枚举语义。
definition = get_object_or_404(
_definitions_for_user(request.user),
public_id=public_id,
)
revisions = definition.revisions.select_related(
"created_by",
"bundle_artifact",
).order_by("-revision")
context = {
"definition": definition,
"revisions": revisions,
"can_publish": (
has_capability(request.user, AUTOMATION_DEFINITION_PUBLISH)
and has_object_action(request.user, "definition", definition.pk, "manage")
),
"can_execute": (
has_capability(request.user, AUTOMATION_DEFINITION_EXECUTE)
and has_object_action(request.user, "definition", definition.pk, "execute")
),
}
return render(request, "automation/definition_detail.html", context)
@login_required
@all_capabilities_required(
(
AUTOMATION_DEFINITION_VIEW,
AUTOMATION_DEFINITION_PUBLISH,
),
)
def definition_revision_create(request, public_id):
"""输入定义与新修订 GET/POST;输出表单或重定向;调用者:URL 路由;失败:全局门缺失 403、对象不可见 404、manage 授权缺失 403,修订校验错误回填表单。"""
# 业务判断:先从当前用户可见范围取定义,不可见与不存在都返回 404;可见但没有 manage 对象授权才返回 403。
definition = get_object_or_404(
_definitions_for_user(request.user),
public_id=public_id,
)
if not has_object_action(request.user, "definition", definition.pk, "manage"):
raise PermissionDenied
current = definition.current_revision
initial = None
if current is not None:
initial = {
"specification": current.specification,
"parameter_schema": current.parameter_schema,
"executor": current.executor,
"timeout_seconds": current.timeout_seconds,
"retry_policy": current.retry_policy,
"risk_level": current.risk_level,
"concurrency_policy": current.concurrency_policy,
"approval_policy": current.approval_policy,
}
form = DefinitionRevisionForm(
request.POST if request.method == "POST" else None,
definition=definition,
initial=initial,
)
if request.method == "POST" and form.is_valid():
try:
revision = add_definition_revision(
definition_id=definition.pk,
specification=form.cleaned_data["specification"],
parameter_schema=form.cleaned_data["parameter_schema"],
executor=form.cleaned_data["executor"],
timeout_seconds=form.cleaned_data["timeout_seconds"],
retry_policy=form.cleaned_data["retry_policy"],
risk_level=form.cleaned_data["risk_level"],
concurrency_policy=form.cleaned_data["concurrency_policy"],
approval_policy=form.cleaned_data["approval_policy"],
created_by=request.user,
)
except DefinitionValidationError as exc:
form.add_error(None, str(exc))
else:
messages.success(
request,
"不可变修订 v%s 已发布。" % revision.revision,
)
return redirect(
"automation:definition_detail",
public_id=definition.public_id,
)
# 返回:GET、空 POST 或校验失败都渲染同一表单;只有服务成功发布修订时才在上方返回 302 重定向。
return render(
request,
"automation/definition_form.html",
{
"form": form,
"page_title": "发布新修订",
"submit_label": "发布不可变修订",
"definition": definition,
},
)
| 函数 | 输入、输出与调用者 | 读取、检查、写入、失败与返回 |
|---|---|---|
_definitions_for_user() | 用户、动作 -> QuerySet;下列三个视图调用 | 读取定义及主体授权,以对象动作收窄;不写数据。无授权时返回空集。 |
definition_list() | 请求(含页码)-> 分页 HTML;列表路由调用 | 装饰器检查登录与全局 view,再读对象可见集;不写数据。匿名跳转登录、能力不足拒绝,空集正常渲染。 |
definition_detail() | 请求、公开 UUID -> 详情 HTML;详情路由调用 | 读可见定义、修订和发布/执行能力,不写数据;缺对象 view 返回 404,否则返回含动作布尔值的 context。 |
definition_revision_create() | 请求、公开 UUID -> 表单响应或重定向;修订路由调用 | 读同一可见集,检查全局 view/publish 与对象 manage;不可见 404、动作不足 403、表单错误回显,成功路径才写新修订。 |
先查全表再检查 view 会用 403/404 差异泄露对象存在性;只隐藏发布按钮则可被直接请求绕过。
8. 启动、审批、取消、输出与 Worker
8.1 启动表单先收窄可选清单
# automation/forms.py:LaunchPreviewForm 完整类
# LaunchPreviewForm 继承 forms.Form 表单基类,因此获得字段收集、绑定数据、is_valid() 和 cleaned_data 等标准表单机制。
# 它不是 ModelForm:只组合启动预览所需输入,不会根据 Meta 自动映射模型,也不会自动提供 save()。
# 视图把当前用户显式传给 __init__,表单再用全局 Capability 与对象 AccessGrant 收窄清单候选范围。
# 这样同一个 queryset 同时约束页面可见选项和 POST 校验,伪造一个未授权清单主键也不会通过。
class LaunchPreviewForm(forms.Form):
# ModelChoiceField 清洗成功后给 Inventory 实例而非原始主键;label 是页面显示的“目标清单”。
# queryset 限制可提交的候选对象;先用 none() 防止模块导入时意外暴露全部清单,__init__ 再按用户授权替换。
inventory = forms.ModelChoiceField(
label="目标清单",
queryset=Inventory.objects.none(),
)
# JSONField 把输入解析为 Python JSON 值;label 是页面显示名,required=False 表示可以不填写参数。
# initial=dict 传入可调用对象,让每个新表单获得独立空字典作为初始值,而不是共享同一个字典。
# widget 指定多行 Textarea 控件;attrs 字典把 rows="10" 写入 HTML,只改变可见高度,不放宽后端校验。
# help_text 是字段旁的帮助文本,提醒输入仍需通过当前不可变修订定义的受限 JSON Schema。
parameters = forms.JSONField(
label="执行参数",
required=False,
initial=dict,
widget=forms.Textarea(attrs={"rows": 10}),
help_text="参数必须符合当前不可变修订的受限 JSON Schema。",
)
def __init__(self, *args, user, **kwargs):
"""输入表单参数和用户,输出按双门授权过滤清单的启动表单;由启动视图调用,数据库查询失败时向上抛出。"""
# 先建立字段,再按用户的全局 Capability 与对象 AccessGrant 双门替换 inventory.queryset。
super().__init__(*args, **kwargs)
inventories = Inventory.objects.filter(is_enabled=True).order_by("name")
if not has_capability(user, AUTOMATION_INVENTORY_USE):
inventories = inventories.none()
else:
inventories = filter_for_object_action(
user,
inventories,
"inventory",
"use",
)
# 将过滤后的惰性 QuerySet 绑定给字段;渲染与提交校验都使用同一授权范围,伪造未列选项也会失败。
self.fields["inventory"].queryset = inventories
def clean_parameters(self):
"""输入执行参数,输出字典且把空值归一化为空对象;由启动表单调用,具体 Schema 失败留给预览服务。"""
return self.cleaned_data.get("parameters") or {}
__init__() 输入表单数据与当前用户,输出初始化完成的表单实例;启动预览视图调用它。它读取启用清单、用户全局 use 能力和对象 use 授权,把字段 QuerySet 收窄。失败表现为伪造主键无法通过 ModelChoiceField。这只是交互层第一道检查,不能替代服务层,因为攻击者可以直接调用提交端点。
clean_parameters() 输入已解析的 JSON 字段,输出字典;空值规范成空字典。真正的参数 Schema 校验仍在定义服务中完成。
8.2 automation/services/launch.py 最终完整文件
# automation/services/launch.py
# 标准库导入:hashlib 计算摘要,json 规范序列化,asdict 把 dataclass 目标转成普通字典。
import hashlib
import json
from dataclasses import asdict
# transaction.atomic 提供数据库事务/回滚边界,不保证所有后端都给不可变读取快照。
from django.db import transaction
# .. 是 automation 包,单点 . 是当前 services 包;相对导入让同一应用内部依赖清晰。
from ..contracts import LaunchPreview
from ..models import DefinitionRevision, ExecutionDefinition, Inventory
from .authorization import AuthorizationError, require_launch_access
from .definitions import (
DefinitionValidationError,
prepare_revision_payload,
validate_parameters,
)
from .targeting import TargetResolutionError, resolve_inventory_targets
# LaunchPreviewError 继承 Python 的 Exception,统一表示可回填到启动表单的预览失败,而不是服务器未知故障。
class LaunchPreviewError(Exception):
"""启动预览失败;视图将消息放入表单而不创建任务。"""
pass
def _canonical_bytes(value):
"""输入预览 JSON 值,输出稳定 UTF-8 字节;由参数与启动摘要计算调用,非法 JSON 值时抛 LaunchPreviewError。"""
try:
# 与定义摘要相同:固定键序/分隔符并拒绝 NaN,再把 str 编成 UTF-8 bytes,保证相同 JSON 语义得到相同输入字节。
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
except (TypeError, ValueError) as exc:
raise LaunchPreviewError("启动预览只能包含有效 JSON 数据。") from exc
def _verify_revision_integrity(definition, revision):
"""输入定义与当前修订,成功时无返回值;由预览构建调用,关联、规范或摘要被篡改时失败。"""
# 业务判断:先确认修订确属该定义,再用统一规范化函数重算不可变内容摘要。
if revision.definition_id != definition.pk:
raise LaunchPreviewError("执行定义的当前修订关联无效。")
try:
prepared = prepare_revision_payload(
definition_type=definition.definition_type,
specification=revision.specification,
parameter_schema=revision.parameter_schema,
executor=revision.executor,
timeout_seconds=revision.timeout_seconds,
retry_policy=revision.retry_policy,
risk_level=revision.risk_level,
concurrency_policy=revision.concurrency_policy,
approval_policy=revision.approval_policy,
bundle_artifact=revision.bundle_artifact,
)
except DefinitionValidationError as exc:
raise LaunchPreviewError("执行定义修订完整性校验失败。") from exc
# 比较“重算摘要”和数据库保存摘要只能发现不一致:SHA-256 是完整性指纹,不是加密或数字签名。
# 若不受信方可同时改修订内容和摘要,它不能证明来源;可信边界仍是限制数据库写权限的应用路径。
if prepared["canonical_sha256"] != revision.canonical_sha256:
raise LaunchPreviewError("执行定义修订摘要不匹配。")
def _inventory_digest_payload(inventory):
"""输入目标清单,输出影响目标解析的摘要字典;由预览构建调用,无独立业务失败。"""
return {
"id": inventory.pk,
"name": inventory.name,
"source": inventory.source,
"selector": inventory.selector,
"address_policy": inventory.address_policy,
"cidr_allowlist": inventory.cidr_allowlist,
"remote_roots": inventory.remote_roots,
"max_targets": inventory.max_targets,
"max_parallelism": inventory.max_parallelism,
}
def build_launch_preview(*, user, definition_id, inventory_id, parameters):
"""输入用户、定义/清单主键和参数,输出 LaunchPreview;由预览与提交服务调用,不存在、越权或校验失败时统一抛错。"""
try:
# 数据库读取:atomic 把一组读取放进同一事务范围;可见性/快照语义仍由数据库隔离级别决定。
# select_related 用 SQL JOIN 预取单值外键,访问 current_revision/default_credential 时避免额外查询;它不加锁。
with transaction.atomic():
definition = (
ExecutionDefinition.objects.select_related(
"current_revision",
"current_revision__bundle_artifact",
)
.get(pk=definition_id)
)
inventory = (
Inventory.objects.select_related(
"default_credential",
"default_credential__current_version",
)
.get(pk=inventory_id)
)
# 业务判断:依次检查启用状态、当前修订完整性、全局 Capability 与对象 AccessGrant 双门、参数 Schema 和目标解析。
if not definition.is_enabled:
raise LaunchPreviewError("执行定义已停用。")
revision = definition.current_revision
if revision is None:
raise LaunchPreviewError("执行定义没有可用修订。")
_verify_revision_integrity(definition, revision)
require_launch_access(user, definition, inventory)
validated_parameters = validate_parameters(revision, parameters)
targets = resolve_inventory_targets(inventory, revision.executor)
parameters_json = _canonical_bytes(validated_parameters).decode("utf-8")
# asdict 递归复制 dataclass 字段;列表推导式保持冻结目标顺序,整个字典随后进入规范 JSON 摘要。
digest_payload = {
"schema_version": 1,
"definition_public_id": str(definition.public_id),
"revision_public_id": str(revision.public_id),
"revision_sha256": revision.canonical_sha256,
"inventory": _inventory_digest_payload(inventory),
"parameters": validated_parameters,
"targets": [asdict(target) for target in targets],
}
# launch_digest 同样只是完整性比较值:覆盖修订、清单策略、参数和目标,但不保密也不认证签名者。
launch_digest = hashlib.sha256(
_canonical_bytes(digest_payload)
).hexdigest()
# 返回:启动摘要覆盖定义修订、清单策略、参数和冻结目标,提交阶段可据此发现预览后的变化。
return LaunchPreview(
definition_public_id=str(definition.public_id),
definition_name=definition.name,
revision_public_id=str(revision.public_id),
revision_number=revision.revision,
revision_sha256=revision.canonical_sha256,
inventory_id=inventory.pk,
inventory_name=inventory.name,
executor=revision.executor,
risk_level=revision.risk_level,
approval_required=bool(revision.approval_policy.get("required")),
parameters_json=parameters_json,
target_count=len(targets),
targets=targets,
launch_digest=launch_digest,
)
except (ExecutionDefinition.DoesNotExist, Inventory.DoesNotExist) as exc:
raise LaunchPreviewError("执行定义或目标清单不存在。") from exc
except (AuthorizationError, DefinitionValidationError, TargetResolutionError) as exc:
raise LaunchPreviewError(str(exc)) from exc
8.3 启动服务逐函数说明
| 函数 | 输入/返回/调用者 | 数据与失败边界 |
|---|---|---|
_canonical_bytes(value) | 输入 JSON 值,返回稳定 UTF-8 字节;预览摘要调用 | 排序键、禁止 NaN、固定分隔符;不可 JSON 化时抛 LaunchPreviewError。不稳定序列化会让同一启动内容产生不同摘要。 |
_verify_revision_integrity(definition, revision) | 输入定义和当前修订,成功无返回;预览调用 | 重新规范化修订并比较 canonical SHA-256;关联或摘要变化时拒绝。只信数据库字段而不重算摘要,会让被越权修改的修订继续执行。 |
_inventory_digest_payload(inventory) | 输入清单,返回参与摘要的非秘密字典 | 读取目标解析和并发限制字段,不包含凭据明文。 |
build_launch_preview(...) | 输入用户、定义主键、清单主键、参数;返回不可含 ORM 对象的 LaunchPreview;预览页与提交服务调用 | 读取定义/修订/清单/默认凭据,检查启用、修订完整性、定义 execute、清单 use、默认凭据 use,校验参数和目标,计算启动摘要;不存在或任一校验失败都 fail closed。 |
8.4 提交时重新构造预览,而不是相信浏览器预览
submit_job() 在事务内锁定定义、清单和手工目标,然后再次调用 build_launch_preview()。因此预览之后撤销对象授权、切换当前修订、修改清单或默认凭据,提交都必须重新检查。浏览器不会提交一个“我已经预览过”的可信令牌。
# automation/services/jobs.py:提交事务中的重新授权与快照冻结
# atomic 保证任务、目标、审批和审计任一步异常都回滚;读取是否为固定快照仍取决于数据库后端与隔离级别。
# select_for_update 在支持行锁的后端只锁已匹配的定义/清单/手工目标行,锁持续到最外层事务结束;SQLite 可能忽略它。
with transaction.atomic():
definition = (
ExecutionDefinition.objects.select_for_update()
.select_related("current_revision")
.get(pk=definition_id)
)
inventory = Inventory.objects.select_for_update().get(pk=inventory_id)
if inventory.source == Inventory.Source.MANUAL:
# values_list 仍惰性;list(...) 强制执行 SELECT FOR UPDATE,若不求值就不会真正取得任何行锁。
list(inventory.manual_targets.select_for_update().values_list("pk", flat=True))
# 业务判断:预览服务再次执行双门授权、修订完整性、参数与目标解析,并冻结本次启动摘要。
preview = build_launch_preview(
user=user,
definition_id=definition_id,
inventory_id=inventory_id,
parameters=parameters,
)
revision = (
DefinitionRevision.objects.select_for_update()
.select_related("bundle_artifact")
.get(public_id=preview.revision_public_id)
)
if definition.current_revision_id != revision.pk:
raise JobSubmissionError("执行定义当前修订已变化,请重新预览。")
normalized_parameters = json.loads(preview.parameters_json)
# 从规范参数字节计算十六进制 SHA-256,供后续重算检测不一致;它不是加密/签名,不能隐藏参数或证明来源。
parameter_sha256 = hashlib.sha256(
_canonical_bytes(normalized_parameters)
).hexdigest()
该片段的输入、输出和调用者属于 submit_job():输入当前用户与启动选择,返回新建 Job。它读取并锁定当前配置,检查预览与当前修订一致,写任务及不可变执行快照。若只在 GET 预览时检查权限,会产生典型 TOCTOU:授权撤销后仍能用旧页面提交。
8.5 审批必须禁止申请人自批
# automation/services/approvals.py
# transaction.atomic 提供事务/回滚范围;timezone 提供时区感知当前时间。
from django.db import transaction
from django.utils import timezone
# accounts.* 是跨应用绝对导入;..models 和单点 .services 是 automation 内部相对导入。
from accounts.capabilities import AUTOMATION_APPROVAL_APPROVE
from accounts.policy import has_capability
# 导入 Approval、Job 和 JobTarget 三个持久化模型,审批决定会在同一事务中推进它们的状态。
from ..models import Approval, Job, JobTarget
# 导入 record_audit_event,在状态写入成功后追加不可缺少的审批审计事件。
from .audit import record_audit_event
# 导入 has_object_action,除全局 Capability 外继续检查具体执行定义的 approve 授权。
from .authorization import has_object_action
# 导入完整性异常和校验函数,防止审批已经变化或被篡改的启动快照。
from .integrity import LaunchIntegrityError, verify_job_integrity
# ApprovalError 继承 Python 的 Exception,作为审批服务可预期失败的统一异常类型供视图捕获。
class ApprovalError(Exception):
"""审批决策失败;调用视图会把消息回填到审批表单。"""
pass
def _expire_locked(approval, now):
"""输入已锁定审批与当前时间,输出无返回值;由审批决策调用,数据库写入失败时事务回滚。"""
# QuerySet.update 直接发 SQL 批量更新,返回影响行数;它绕过模型 save()/full_clean() 与 pre/post_save 信号。
# 内存中的 approval/job 不会自动同步,读取新值前必须 refresh_from_db();条件字段也用于避免覆盖并发变化。
Approval.objects.filter(
pk=approval.pk,
status=Approval.Status.PENDING,
).update(
status=Approval.Status.EXPIRED,
decided_at=now,
reason="审批已过期。",
)
JobTarget.objects.filter(
job=approval.job,
status=JobTarget.Status.QUEUED,
).update(
status=JobTarget.Status.CANCELLED,
error_code="approval_expired",
error_message="审批已过期。",
finished_at=now,
updated_at=now,
)
Job.objects.filter(
pk=approval.job_id,
status=Job.Status.AWAITING_APPROVAL,
).update(
status=Job.Status.CANCELLED,
error_code="approval_expired",
error_message="审批已过期。",
cancelled_targets=approval.job.total_targets,
finished_at=now,
updated_at=now,
)
record_audit_event(
event_type="approval.expired",
job=approval.job,
payload={"approval_public_id": str(approval.public_id)},
)
def decide_approval(*, approval_id, reviewer, approve, reason=""):
"""输入审批主键、审批人、布尔决定和意见,输出最新 Approval;由审批 POST 视图调用,身份、授权、状态或完整性不符时失败。"""
# 业务判断:先验证请求协议、意见长度、账号状态和全局审批 Capability。
if not isinstance(approve, bool):
raise ApprovalError("审批决定必须是布尔值。")
if not isinstance(reason, str) or len(reason) > 2000:
raise ApprovalError("审批意见不能超过 2000 个字符。")
if not reviewer.is_authenticated or not reviewer.is_active:
raise ApprovalError("当前用户不能审批任务。")
if not has_capability(reviewer, AUTOMATION_APPROVAL_APPROVE):
raise ApprovalError("当前用户没有任务审批权限。")
now = timezone.now()
# atomic 保证审批、任务、目标及审计写入异常时回滚;select_for_update 只在支持后端锁住已匹配审批行,SQLite 可能忽略。
# 行锁不覆盖不存在的审批,也不自动把整个事务变成跨后端不可变快照。
with transaction.atomic():
approval = (
Approval.objects.select_for_update()
.select_related("job", "job__definition_revision")
.get(pk=approval_id)
)
# 业务判断:过期审批走取消收口;申请人与审批人相同则拒绝自批,即使其拥有全局和对象审批权也不例外。
if approval.status != Approval.Status.PENDING:
raise ApprovalError("审批已经处理,不能重复决定。")
if approval.expires_at <= now:
_expire_locked(approval, now)
approval.refresh_from_db()
approval.job.refresh_from_db()
return approval
if approval.requester_id == reviewer.pk:
raise ApprovalError("任务申请人不能审批自己的任务。")
if approval.job.status != Job.Status.AWAITING_APPROVAL:
raise ApprovalError("任务状态已变化,审批失效。")
try:
verify_job_integrity(approval.job)
except LaunchIntegrityError as exc:
raise ApprovalError("任务启动快照已变化,审批失效。") from exc
if approval.launch_digest != approval.job.launch_digest:
raise ApprovalError("启动摘要已变化,审批失效。")
# 业务判断:审批同样采用双门;全局 Capability 已在事务外检查,这里再检查具体定义的 approve AccessGrant。
definition_id = approval.job.definition_revision.definition_id
if not has_object_action(reviewer, "definition", definition_id, "approve"):
raise ApprovalError("当前用户没有该执行定义的审批授权。")
# 保存阶段:以下条件 update() 把审批决定及其对应任务、目标状态写入数据库;全部绕过模型钩子。
# changed 必须为 1 才说明条件更新真正推进了唯一任务,否则回滚并报告并发状态变化。
approval_status = (
Approval.Status.APPROVED if approve else Approval.Status.REJECTED
)
Approval.objects.filter(
pk=approval.pk,
status=Approval.Status.PENDING,
).update(
status=approval_status,
reviewer=reviewer,
reason=reason,
decided_at=now,
)
if approve:
changed = Job.objects.filter(
pk=approval.job_id,
status=Job.Status.AWAITING_APPROVAL,
launch_digest=approval.launch_digest,
).update(
status=Job.Status.QUEUED,
queued_at=now,
updated_at=now,
)
if changed != 1:
raise ApprovalError("任务状态已变化,审批失效。")
else:
JobTarget.objects.filter(
job=approval.job,
status=JobTarget.Status.QUEUED,
).update(
status=JobTarget.Status.CANCELLED,
error_code="approval_rejected",
error_message="任务审批被拒绝。",
finished_at=now,
updated_at=now,
)
changed = Job.objects.filter(
pk=approval.job_id,
status=Job.Status.AWAITING_APPROVAL,
launch_digest=approval.launch_digest,
).update(
status=Job.Status.CANCELLED,
error_code="approval_rejected",
error_message="任务审批被拒绝。",
cancelled_targets=approval.job.total_targets,
finished_at=now,
updated_at=now,
)
if changed != 1:
raise ApprovalError("任务状态已变化,审批失效。")
# 返回:刷新审批和任务状态后记录审计,再把最终审批对象交还视图。
approval.refresh_from_db()
approval.job.refresh_from_db()
record_audit_event(
event_type="approval.decided",
actor=reviewer,
job=approval.job,
payload={
"approval_public_id": str(approval.public_id),
"decision": approval.status,
"reason": reason,
"launch_digest": approval.launch_digest,
},
)
return approval
_expire_locked() 输入已经加锁的审批和当前时间,无业务返回值;decide_approval() 在过期分支调用。它把审批、排队目标和任务一起转为终态并写审计。decide_approval() 输入审批主键、审核人、布尔决定和意见,返回刷新后的审批;审批 POST 视图调用。它先检查输入与全局能力,再锁审批,检查 pending、有效期、申请人与审核人不同、任务状态、快照完整性、启动摘要和对象 approve 授权,最后用带旧状态条件的更新写审批与任务。
approval.requester_id == reviewer.pk 必须在服务层而不是只隐藏按钮。审批 API、管理命令或未来消息消费者都可能绕过模板。模型约束 automation_approval_no_self_review 再阻止申请人与审核人为同一用户,构成数据库防线。带 status=PENDING 和 launch_digest 的条件更新是第二道竞态防线,避免两个审核人同时成功或审批已经变化的启动内容。
8.6 取消同时检查全局能力、对象动作和状态机
# automation/services/state_machine.py:request_job_cancellation() 完整函数
def request_job_cancellation(*, job_id, requested_by, reason=""):
"""输入任务、请求人和原因,输出聚合后 Job;由取消视图调用,身份、双门授权或状态不允许时失败。"""
# 业务判断:先校验原因、账号状态和全局 cancel Capability;对象授权在读取具体任务后再检查。
if not isinstance(reason, str) or len(reason) > 500:
raise StateTransitionError("取消原因不能超过 500 个字符。")
if not requested_by.is_authenticated or not requested_by.is_active:
raise StateTransitionError("当前用户不能取消任务。")
if not has_capability(requested_by, AUTOMATION_JOB_CANCEL):
raise StateTransitionError("当前用户没有任务取消权限。")
now = timezone.now()
# 数据库读取:锁定任务并带出定义修订,确保授权判断与取消状态更新基于同一版本。
with transaction.atomic():
job = (
Job.objects.select_for_update()
.select_related("definition_revision")
.get(pk=job_id)
)
# 业务判断:有定义的任务要求该定义 cancel AccessGrant;无定义的遗留任务只允许申请人或超级用户取消。
if job.definition_revision_id:
definition_id = job.definition_revision.definition_id
if not has_object_action(requested_by, "definition", definition_id, "cancel"):
raise StateTransitionError("当前用户没有该执行定义的取消授权。")
elif job.requested_by_id != requested_by.pk and not requested_by.is_superuser:
raise StateTransitionError("当前用户不能取消该任务。")
if job.status in JOB_TERMINAL_STATUSES:
return job
if job.status not in {
Job.Status.AWAITING_APPROVAL,
Job.Status.QUEUED,
Job.Status.RUNNING,
Job.Status.CANCEL_REQUESTED,
}:
raise StateTransitionError("任务当前状态不能取消。")
# 保存阶段:下列批量 update 直接写数据库并绕过模型校验/信号;queued 目标立即终止,运行目标留给 Worker 协作停止。
JobTarget.objects.filter(
job=job,
status=JobTarget.Status.QUEUED,
).update(
status=JobTarget.Status.CANCELLED,
error_code="cancelled_before_start",
error_message="任务在执行前被取消。",
finished_at=now,
updated_at=now,
)
JobTarget.objects.filter(
job=job,
status=JobTarget.Status.RUNNING,
).update(
status=JobTarget.Status.CANCEL_REQUESTED,
updated_at=now,
)
has_running = JobTarget.objects.filter(
job=job,
status__in=(
JobTarget.Status.RUNNING,
JobTarget.Status.CANCEL_REQUESTED,
),
).exists()
next_status = (
Job.Status.CANCEL_REQUESTED if has_running else Job.Status.CANCELLED
)
Job.objects.filter(pk=job.pk).update(
status=next_status,
cancel_requested_at=now,
cancel_requested_by=requested_by,
cancel_reason=reason,
finished_at=None if has_running else now,
updated_at=now,
)
job.refresh_from_db()
record_audit_event(
event_type="job.cancel_requested",
actor=requested_by,
job=job,
payload={"status": job.status, "reason": reason},
)
# 返回:事务提交后重新聚合任务及其目标状态,并把最新 Job 返回取消视图。
return aggregate_job(job_id)
request_job_cancellation() 输入任务主键、操作者和原因,返回聚合后的任务;取消确认页 POST 调用。它读取并锁任务,检查用户激活、全局 cancel、定义 cancel 对象动作以及合法状态;写排队目标取消、运行目标取消请求、任务取消元数据和审计。终态重复取消是幂等返回。危险捷径是直接把运行中任务写成 CANCELLED:Worker 可能仍在输出,最终状态会与真实执行冲突。
8.7 HTML、JSON 轮询与 SSE 使用同一输出授权
# automation/views.py:统一输出授权及三个入口
def _can_view_job_output(user, job):
"""输入用户与任务;输出能否读取输出;调用者:详情页、轮询和 SSE;失败:本函数把全局/对象双门或高风险附加能力缺失统一表示为 False,授权查询异常继续上抛。"""
# 业务判断:先过全局 output view,再过定义 view_output 对象授权;高/严重风险还要求 restricted output Capability。
if not has_capability(user, AUTOMATION_JOB_OUTPUT_VIEW):
return False
if not user.is_superuser:
if not job.definition_revision_id or not has_object_action(
user,
"definition",
job.definition_revision.definition_id,
"view_output",
):
return False
if job.risk_level in ("high", "critical"):
return has_capability(user, AUTOMATION_RESTRICTED_OUTPUT_VIEW)
return True
@login_required
@capability_required(AUTOMATION_JOB_VIEW)
def job_detail(request, public_id):
"""输入任务公开 ID;输出详情页;调用者:URL 路由;失败:缺全局能力时 403,不存在或对象越权时 404,数据库或模板异常继续上抛。"""
# 业务判断:先以对象 view 授权收窄任务;目标、审计和输出再分别按附加能力裁剪,避免详情页扩大授权。
job = _visible_job_or_404(request.user, public_id)
targets = (
job.targets.select_related("credential_version").all()
if has_capability(request.user, AUTOMATION_JOB_TARGET_VIEW)
else None
)
audit_events = (
job.audit_events.select_related("actor", "attempt").all()
if has_capability(request.user, AUTOMATION_AUDIT_EVENT_VIEW)
else None
)
context = {
"job": job,
"targets": targets,
"audit_events": audit_events,
"can_cancel": _can_cancel_job(request.user, job),
}
# 数据库保存:context.update() 只合并 Python 字典,不执行 ORM 保存;它把统一输出授权结果和尝试列表加入模板上下文。
context.update(_job_output_context(request.user, job))
# 返回:render() 用完整上下文生成任务详情 HttpResponse。
return render(request, "automation/job_detail.html", context)
@login_required
@capability_required(AUTOMATION_JOB_VIEW)
@require_GET
def job_output_poll(request, public_id):
"""输入输出轮询 GET;输出增量 JSON;调用者:浏览器轮询;失败:非 GET 405、输出双门缺失 403、任务越权 404、参数错误 400。"""
# 业务判断:@require_GET 先限制方法;可见任务查询把不存在/越权统一成 404,随后用统一输出判定决定是否返回 403。
job = _visible_job_or_404(request.user, public_id)
if not _can_view_job_output(request.user, job):
raise PermissionDenied
try:
cursor, limit = _parse_output_options(request)
attempt_id = _output_attempt_id(job, request)
except ValueError as exc:
return JsonResponse({"error": str(exc)}, status=400)
chunks, has_more = _output_chunks(job.pk, cursor, limit, attempt_id)
next_cursor = chunks[-1].pk if chunks else cursor
# JsonResponse 接受字典并用 Django JSON 编码器序列化,设置 application/json;默认 HTTP 200。
return JsonResponse(
{
"job": _serialize_job_status(job),
"chunks": [_serialize_output_chunk(chunk) for chunk in chunks],
"next_cursor": next_cursor,
"has_more": has_more,
"complete": job.status in JOB_TERMINAL_STATUSES and not has_more,
}
)
@login_required
@capability_required(AUTOMATION_JOB_VIEW)
@require_GET
def job_output_events(request, public_id):
"""输入输出 SSE GET;输出 StreamingHttpResponse;调用者:EventSource/客户端;失败:方法、授权、对象和参数错误分别返回 405/403/404/400。"""
# 业务判断:建连前完整复核方法、任务可见性、统一输出授权和游标参数;同步失败必须在响应头发送前返回。
job = _visible_job_or_404(request.user, public_id)
if not _can_view_job_output(request.user, job):
raise PermissionDenied
try:
cursor, limit = _parse_output_options(request, use_last_event_id=True)
attempt_id = _output_attempt_id(job, request)
except ValueError as exc:
return JsonResponse({"error": str(exc)}, status=400)
# StreamingHttpResponse 不先拼完整正文,而是逐次迭代生成器并把字符串编码后发送;content_type 声明 SSE UTF-8 协议。
# 响应头一旦发送,生成器后续异常不能再改成 403/404/500 页面,因此所有可同步验证的错误都在创建响应前处理。
response = StreamingHttpResponse(
_sse_output_stream(
request.user,
job.pk,
cursor,
limit,
attempt_id,
),
content_type="text/event-stream; charset=utf-8",
)
# no-cache/no-store 要求客户端/代理不缓存事件流;X-Accel-Buffering=no 提示 Nginx 不要攒批,否则事件不能及时到达。
response["Cache-Control"] = "no-cache, no-store"
response["X-Accel-Buffering"] = "no"
# 返回:设置禁止缓存与代理缓冲的响应头后,把持续迭代事件生成器的 SSE 响应交给客户端。
return response
| 函数 | 输入、输出与调用者 | 读取、检查、写入与失败 |
|---|---|---|
_can_view_job_output() | 用户、任务 -> 布尔值;HTML、轮询、SSE 调用 | 读全局 output、定义 view_output 和高/关键风险额外能力;不写数据。 |
job_detail() | 请求、任务 UUID -> HTML;详情路由调用 | 读可见任务、取消与输出授权,返回 context;不写数据。不可见 404,输出不足只隐藏区域。 |
job_output_poll() | 请求、UUID、cursor/limit/attempt -> JSON;轮询路由调用 | 先查可见性和完整输出授权,再读已脱敏 OutputChunk;不写数据。不可见 404、授权不足 403、参数非法失败。 |
job_output_events() | 请求、UUID、游标 -> StreamingHttpResponse;SSE 路由调用 | 同样鉴权并只读脱敏输出,支持 Last-Event-ID;不写业务数据。失败为 404/403,不能另设免鉴权流地址。 |
HTML 隐藏不是 API 防线;JSON/SSE 必须独立授权。SSE 流循环仍以用户可见任务 QuerySet 读取任务。
8.8 Worker 查看、管理和 POST-only 变更
# automation/views.py:Worker 相关视图
@login_required
@capability_required(AUTOMATION_WORKER_VIEW)
def worker_list(request):
"""输入 Worker 查看请求;输出含在线推断的列表页;调用者:URL 路由;失败:匿名用户重定向,缺全局查看能力时 403,数据库或模板异常继续上抛。"""
now = timezone.now()
# list(...) 立即执行 Worker QuerySet;stale_after 是心跳新鲜度窗口,last_heartbeat_at 早于 now-90s 即视为离线。
workers = list(WorkerHeartbeat.objects.select_related("drain_requested_by").all())
stale_after = timedelta(seconds=90)
for worker in workers:
# stopped_at 非空表示 Worker 已显式停止;last_heartbeat_at 是 Worker 最近登记心跳的时间,两者共同推导临时 is_online 展示属性。
worker.is_online = (
worker.stopped_at is None
and worker.last_heartbeat_at >= now - stale_after
)
return render(request, "automation/worker_list.html", {"workers": workers})
@login_required
@all_capabilities_required(
(AUTOMATION_WORKER_VIEW, AUTOMATION_WORKER_MANAGE),
)
@require_POST
def worker_drain(request, worker_id):
"""输入 Worker 编码及 drain/resume POST;输出列表重定向或 400;调用者:URL 路由;失败:非 POST 405、双全局能力缺失 403、服务拒绝转 403。"""
# 业务判断:装饰器使匿名请求 302、缺任一能力 403、非 POST 405;进入函数后只接受 drain/resume 两个动作。
# Worker HTTP 边界:drain 表示不再领取新目标,resume 恢复领取;服务会记录 requested_by/请求时间,不能任意写心跳时间戳。
action = request.POST.get("action")
if action not in ("drain", "resume"):
# JsonResponse 把字典编码成 application/json;status=400 显式设置 HTTP 状态,不是重定向。
return JsonResponse({"error": "Worker 操作无效。"}, status=400)
try:
worker = set_worker_draining(
worker_id=worker_id,
draining=action == "drain",
requested_by=request.user,
)
except WorkerRegistryError as exc:
raise PermissionDenied(str(exc))
messages.success(
request,
"Worker %s 已%s。"
% (worker.worker_id, "请求 Drain" if action == "drain" else "恢复接收任务"),
)
# 返回:服务成功后用 redirect 生成 302,让浏览器回到 Worker 列表并显示一次性成功消息。
return redirect("automation:worker_list")
worker_list() 输入请求,返回 HTML;它读取 Worker 心跳和请求 Drain 的用户,按 90 秒计算只读的 is_online 展示属性。worker_drain() 输入 POST 请求和 Worker 标识,返回重定向;它检查 view+manage 全局能力、动作白名单,调用注册表服务写 Drain 状态。@require_POST 让 GET 不能改变 Worker;Django CSRF 中间件继续保护浏览器 POST。不能把 drain 写成带查询参数的 GET,链接预取、爬虫或缓存都可能误触发。
9. 对象授权之外保持不变的执行安全基线
本次只把授权主体和入口边界接到既有执行核心,不改变下面的并发与安全合同:
| 合同 | 实现位置 | 保持不变的含义 |
|---|---|---|
| 启动快照 | automation/services/jobs.py、automation/services/integrity.py | 冻结定义修订、清单、规范化参数、目标和凭据版本;审批与执行校验 launch_digest。 |
| 租约 | automation/models/execution.py、automation/services/state_machine.py | Worker claim 带 lease_expires_at;过期 Worker 不能继续心跳、写输出或完成目标。 |
| Fencing | JobTarget.fencing_generation、JobAttempt.fencing_generation | 重新 claim 会增加世代;旧 Worker 即使恢复,也因世代不匹配而不能落最终状态。 |
| 状态机 | automation/services/state_machine.py | 所有转换检查旧状态、锁和条件更新;终态、重试、取消、未知结果不靠任意 save() 跳转。 |
| 流式脱敏 | automation/services/redaction.py、automation/services/output.py | 秘密跨 chunk 边界仍被遮盖;数据库只收到脱敏文本,输出截断写追加标记。 |
| Provider 同步 | cmdb/providers/base.py、cmdb/services/sync.py | 云 Provider 规范化、同步运行、资源 upsert/retire 和审计边界不因 Automation 对象授权改变。 |
授权检查解决“谁能请求”;快照、租约、Fencing、状态机和脱敏解决“请求获准后怎样可靠执行”。把两者合并成一个布尔判断会丢失并发语义。例如撤销用户权限不能修改已经冻结的历史快照,但可以阻止新的预览、提交、查看输出、审批和取消请求;运行中的 Worker 仍必须按租约与取消状态机收尾。
9.1 Automation 授权验证命令
目录:项目根目录,也就是能看到 manage.py、accounts/、cmdb/、automation/ 的目录。
python manage.py check
python manage.py showmigrations accounts automation
python manage.py test automation.tests.test_authorization -v 2
python manage.py test automation.tests.test_definition_views -v 2
python manage.py test automation.tests.test_execution_views -v 2
python manage.py test -v 1
目的:先检查 Django 配置与迁移,再分别验证主体、404/403、启动与输出边界,最后运行完整集成套件。当前预期总结果:Ran 262 tests 和 OK。常见错误包括:没有先运行 RBAC bootstrap 导致能力目录缺失;只给 Role 全局能力却没有对象授权;Role 已停用;给清单 use 却漏掉默认凭据 use;使用 GET 调用 Worker 变更得到 405;使用没有 view 的对象地址得到 404 而不是预期的 403。
9.2 当前对象授权的准确覆盖范围
必须按当前实现写清边界:执行定义列表、详情、发布、启动、定义派生任务、审批、取消和输出已经使用定义对象授权;启动时还检查清单 use 与默认凭据 use。但是凭据管理页面和清单/手工目标管理页面当前仍只检查全局 Capability,没有逐对象收窄;创建凭据和创建清单也没有像执行定义那样自动给创建者写 AccessGrant。namespaces、mutate、delete 已进入模型白名单,但当前授权服务尚未消费它们。
因此,不应把本文描述成“所有 Automation CRUD 都已经完成对象隔离”。准确说法是:定义及其执行链路采用全局能力与对象授权交集;清单和凭据的对象 use 在启动边界强制执行;管理 UI 仍是全局能力边界。后续若把管理 UI 也改成对象隔离,必须同步修改列表 QuerySet、详情 404、动作 403、创建者授权和测试,不能只在按钮上增加判断。
SSE 还有一个明确时效边界:建立连接时会检查 _can_view_job_output(),流循环会重新检查任务是否仍可见,但不会在每次循环重新计算 view_output 与受限输出能力。当前流最长 30 秒,因此只撤销输出动作后,已建立连接最多可能继续到本次流自然结束;下一次连接会重新鉴权。若业务要求即时撤权,应在流循环内重新调用完整输出授权,并评估每次数据库查询成本。
10. Automation 安全矩阵与第 4 篇边界
10.1 最终对象授权矩阵
| 场景 | 通过条件 | 拒绝边界与禁止捷径 |
|---|---|---|
| 列表/详情/动作 | 全局能力与对象动作交集 | 不可见 404,动作不足 403;不能只藏按钮 |
| 创建定义 | 同事务授予 view/manage/execute | 失败整体回滚;不能异步补授权 |
| 启动 | 定义 execute、清单 use、默认凭据 use 都通过两道门 | 预览和提交都检查;不信旧预览或浏览器主键 |
| 审批/取消 | 对象动作、合法状态;审批还要求不是申请人 | 不能自批,也不能直接终结运行中任务 |
| 输出/Worker | 输出分级授权;Worker view/manage 分离 | HTML、JSON、SSE 同规则;管理动作只接受 POST |
10.2 第 4 篇边界
第 4 篇固定地址是 https://www.cnblogs.com/lizexiong/p/22716152。它从第三方身份威胁模型开始,完整讲解 Fake Provider、真实 Provider 失败关闭边界、state、PKCE、nonce、绑定、登录、解绑和审计,最后交付账号教学源码 ZIP。

浙公网安备 33010602011771号