用户与权限管理(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/22712552Policy 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(...)输入用户、定义、清单;成功返回 Nonebuild_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.pyWorker claim 带 lease_expires_at;过期 Worker 不能继续心跳、写输出或完成目标。
FencingJobTarget.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。

posted @ 2026-08-27 10:51  小家电维修  阅读(7)  评论(0)    收藏  举报