feat(admin): Phase 0 平台超管基础 — is_platform_admin + IsPlatformAdmin + create_platform_admin(admin/admin123) + AdminAuditLog + Admin 后台外壳与路由 gating
后端:User.is_platform_admin + migration;权限类 IsPlatformAdmin;管理命令建 admin/admin123(幂等); AdminAuditLog 模型 + log_admin_action() helper;me/login 对无团队超管优雅返回 team=null;UserSerializer 暴露标志。 前端:routes/admin 后台外壳(分组侧栏 + 概览 + 占位)、/admin 路由解析与 gating(超管直落、非超管纠回)、 侧栏平台入口、admin-page.css(仅 token)、IconKitSvg 补图标。 测试:accounts 11/11 单测过;无头 e2e _admin-p0.mjs 全断言过 + 0 console error;tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8fc3870fa3
commit
443023a1a9
@@ -0,0 +1,42 @@
|
||||
"""平台超管审计写入 helper。失败绝不阻断主流程(审计是旁路)。"""
|
||||
|
||||
|
||||
def _client_ip(request):
|
||||
if request is None:
|
||||
return None
|
||||
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.META.get("REMOTE_ADDR") or None
|
||||
|
||||
|
||||
def log_admin_action(
|
||||
request,
|
||||
action,
|
||||
*,
|
||||
target_type="",
|
||||
target_id="",
|
||||
target_name="",
|
||||
before=None,
|
||||
after=None,
|
||||
):
|
||||
"""记录一条平台超管操作审计。action 用动词短语(如 'invite.issue' / 'team.disable')。
|
||||
operator_name 取当前登录用户名快照。任何异常吞掉,不影响业务返回。"""
|
||||
from .models import AdminAuditLog
|
||||
|
||||
try:
|
||||
user = getattr(request, "user", None)
|
||||
operator = user if (user is not None and getattr(user, "is_authenticated", False)) else None
|
||||
AdminAuditLog.objects.create(
|
||||
operator=operator,
|
||||
operator_name=(getattr(operator, "username", "") or ""),
|
||||
action=action,
|
||||
target_type=target_type or "",
|
||||
target_id=str(target_id or ""),
|
||||
target_name=target_name or "",
|
||||
before=before,
|
||||
after=after,
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 审计失败不应阻断主流程
|
||||
pass
|
||||
@@ -0,0 +1,61 @@
|
||||
"""创建/确保平台超级管理员账号。
|
||||
|
||||
默认 admin / admin123(本期定调:用户第一个超管)。平台超管不属于任何团队 —— 登录后
|
||||
前端直落 /admin 后台。重复执行幂等:已存在则只补 is_platform_admin 标志(可选 --reset-password 重置密码)。
|
||||
|
||||
python manage.py create_platform_admin
|
||||
python manage.py create_platform_admin --username admin --password admin123 --reset-password
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "创建或确保平台超级管理员(默认 admin/admin123)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--username", default="admin", help="超管用户名(默认 admin)")
|
||||
parser.add_argument("--password", default="admin123", help="超管密码(默认 admin123)")
|
||||
parser.add_argument(
|
||||
"--reset-password",
|
||||
action="store_true",
|
||||
help="账号已存在时也强制重置为给定密码",
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
from apps.accounts.models import User
|
||||
|
||||
username = options["username"]
|
||||
password = options["password"]
|
||||
reset = options["reset_password"]
|
||||
|
||||
user, created = User.objects.get_or_create(
|
||||
username=username,
|
||||
defaults={"email": f"{username}@airshelf.local"},
|
||||
)
|
||||
changed = []
|
||||
if not user.is_platform_admin:
|
||||
user.is_platform_admin = True
|
||||
changed.append("is_platform_admin")
|
||||
# 也给 Django staff 标志,便于需要时进 Django admin;不影响业务权限(业务只看 is_platform_admin)
|
||||
if not user.is_staff:
|
||||
user.is_staff = True
|
||||
changed.append("is_staff")
|
||||
if user.status != User.Status.ACTIVE:
|
||||
user.status = User.Status.ACTIVE
|
||||
changed.append("status")
|
||||
if created or reset:
|
||||
user.set_password(password)
|
||||
changed.append("password")
|
||||
user.save()
|
||||
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f"已创建平台超管 {username}(密码 {password})"))
|
||||
elif changed:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"平台超管 {username} 已更新:{', '.join(changed)}")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f"平台超管 {username} 已存在,无需变更"))
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-19 12:35
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_invitation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_platform_admin',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AdminAuditLog',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('operator_name', models.CharField(blank=True, max_length=150)),
|
||||
('action', models.CharField(max_length=48)),
|
||||
('target_type', models.CharField(blank=True, max_length=32)),
|
||||
('target_id', models.CharField(blank=True, max_length=64)),
|
||||
('target_name', models.CharField(blank=True, max_length=200)),
|
||||
('before', models.JSONField(blank=True, null=True)),
|
||||
('after', models.JSONField(blank=True, null=True)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('operator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='admin_audit_logs', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['action'], name='accounts_ad_action_46dc9d_idx'), models.Index(fields=['-created_at'], name='accounts_ad_created_1ae9d4_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -15,6 +15,9 @@ class User(AbstractUser):
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
avatar_url = models.URLField(blank=True)
|
||||
# 平台超级管理员:凌驾于团队 owner 之上的跨团队后台权限(发开团队码 / 管所有团队 / 治理)。
|
||||
# 与团队解耦 —— 平台超管可不属于任何团队(get_current_team 对其返回 None,前端登录直落 /admin)。
|
||||
is_platform_admin = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def is_disabled(self) -> bool:
|
||||
@@ -145,3 +148,31 @@ class Invitation(TimeStampedModel):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.team} / {self.code} / {self.status}"
|
||||
|
||||
|
||||
class AdminAuditLog(TimeStampedModel):
|
||||
"""平台超管操作审计:谁(operator)在什么时间对哪个对象(target)做了什么(action),
|
||||
可选 before/after 快照。operator_name 单独存档,即使账号被删审计仍可读。所有跨团队
|
||||
后台写操作(发码 / 启停团队 / 改密 / 调额 / 改模型等)都应经 log_admin_action() 落一条。"""
|
||||
|
||||
operator = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True, related_name="admin_audit_logs"
|
||||
)
|
||||
operator_name = models.CharField(max_length=150, blank=True)
|
||||
action = models.CharField(max_length=48)
|
||||
target_type = models.CharField(max_length=32, blank=True)
|
||||
target_id = models.CharField(max_length=64, blank=True)
|
||||
target_name = models.CharField(max_length=200, blank=True)
|
||||
before = models.JSONField(null=True, blank=True)
|
||||
after = models.JSONField(null=True, blank=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["action"]),
|
||||
models.Index(fields=["-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.operator_name} / {self.action} / {self.target_type}:{self.target_id}"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""平台级权限类。团队级权限仍走 apps.common.api.can_manage_team(owner/admin)。
|
||||
|
||||
平台超管(User.is_platform_admin)= 跨团队后台权限,凌驾团队 owner 之上;所有 /api/admin/*
|
||||
端点统一挂 IsPlatformAdmin,非超管一律 403。"""
|
||||
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
|
||||
class IsPlatformAdmin(BasePermission):
|
||||
"""仅平台超级管理员放行。被停用账号即便有标志也拒绝。"""
|
||||
|
||||
message = "需要平台超级管理员权限"
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
user = getattr(request, "user", None)
|
||||
return bool(
|
||||
user
|
||||
and user.is_authenticated
|
||||
and getattr(user, "is_platform_admin", False)
|
||||
and not getattr(user, "is_disabled", False)
|
||||
)
|
||||
@@ -8,8 +8,11 @@ from .models import Invitation, LoginSession, Team, TeamMember, User, UserPrefer
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "first_name", "last_name", "email", "phone", "avatar_url", "status"]
|
||||
read_only_fields = ["id", "status"]
|
||||
fields = [
|
||||
"id", "username", "first_name", "last_name", "email", "phone",
|
||||
"avatar_url", "status", "is_platform_admin",
|
||||
]
|
||||
read_only_fields = ["id", "status", "is_platform_admin"]
|
||||
|
||||
|
||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -116,3 +116,86 @@ class InvitationFlowTests(TestCase):
|
||||
gen2 = joiner_client.post("/api/auth/team/invitations/", {"role": "member"}, format="json")
|
||||
self.assertEqual(gen2.status_code, 403)
|
||||
|
||||
|
||||
class PlatformAdminTests(TestCase):
|
||||
"""Phase 0:平台超管基础 —— 管理命令 / 权限类 / 登录 me 返回标志且团队为空。"""
|
||||
|
||||
def test_create_platform_admin_command(self):
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("create_platform_admin")
|
||||
admin = User.objects.get(username="admin")
|
||||
self.assertTrue(admin.is_platform_admin)
|
||||
self.assertTrue(admin.is_staff)
|
||||
self.assertEqual(admin.status, User.Status.ACTIVE)
|
||||
self.assertTrue(admin.check_password("admin123"))
|
||||
|
||||
def test_create_platform_admin_idempotent_no_password_reset(self):
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("create_platform_admin", "--username", "root", "--password", "rootpass1")
|
||||
# 二次执行不带 --reset-password:不应覆盖已设密码
|
||||
call_command("create_platform_admin", "--username", "root", "--password", "different2")
|
||||
admin = User.objects.get(username="root")
|
||||
self.assertTrue(admin.is_platform_admin)
|
||||
self.assertTrue(admin.check_password("rootpass1"))
|
||||
# 带 --reset-password 才重置
|
||||
call_command("create_platform_admin", "--username", "root", "--password", "fresh3pass", "--reset-password")
|
||||
admin.refresh_from_db()
|
||||
self.assertTrue(admin.check_password("fresh3pass"))
|
||||
|
||||
def test_login_returns_flag_and_null_team_for_teamless_admin(self):
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("create_platform_admin")
|
||||
client = APIClient()
|
||||
r = client.post("/api/auth/login/", {"username": "admin", "password": "admin123"}, format="json")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.data["user"]["is_platform_admin"])
|
||||
self.assertIsNone(r.data["team"])
|
||||
|
||||
def test_me_returns_platform_admin_flag(self):
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("create_platform_admin")
|
||||
client = APIClient()
|
||||
login = client.post("/api/auth/login/", {"username": "admin", "password": "admin123"}, format="json")
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Token {login.data['token']}")
|
||||
me = client.get("/api/auth/me/")
|
||||
self.assertEqual(me.status_code, 200)
|
||||
self.assertTrue(me.data["user"]["is_platform_admin"])
|
||||
self.assertIsNone(me.data["team"])
|
||||
|
||||
def test_is_platform_admin_permission(self):
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from apps.accounts.permissions import IsPlatformAdmin
|
||||
|
||||
admin = User.objects.create_user(username="padmin", password="strong-pass-1", is_platform_admin=True)
|
||||
normal = User.objects.create_user(username="normal-user", password="strong-pass-1")
|
||||
perm = IsPlatformAdmin()
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get("/api/admin/ping/")
|
||||
|
||||
request.user = admin
|
||||
self.assertTrue(perm.has_permission(request, None))
|
||||
|
||||
request.user = normal
|
||||
self.assertFalse(perm.has_permission(request, None))
|
||||
|
||||
# 被停用的超管也拒绝
|
||||
admin.status = User.Status.DISABLED
|
||||
admin.save(update_fields=["status"])
|
||||
request.user = admin
|
||||
self.assertFalse(perm.has_permission(request, None))
|
||||
|
||||
def test_normal_register_user_is_not_platform_admin(self):
|
||||
client = APIClient()
|
||||
r = client.post(
|
||||
"/api/auth/register/",
|
||||
{"username": "regular-owner", "password": "strong-password", "team_name": "Reg Team"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertFalse(r.data["user"]["is_platform_admin"])
|
||||
|
||||
|
||||
@@ -29,10 +29,21 @@ def auth_payload(user, team, token):
|
||||
return {
|
||||
"token": token.key,
|
||||
"user": UserSerializer(user).data,
|
||||
"team": TeamSerializer(team).data,
|
||||
"team": TeamSerializer(team).data if team is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def current_team_or_none(user):
|
||||
"""平台超管可不属于任何团队;此时取团队返回 None(而非抛 PermissionDenied)。
|
||||
普通用户无团队仍属异常,但后台账号体系下登录/资料接口不应因此 500。"""
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
try:
|
||||
return get_current_team(user)
|
||||
except PermissionDenied:
|
||||
return None
|
||||
|
||||
|
||||
def _client_ip(request):
|
||||
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||||
if forwarded:
|
||||
@@ -84,7 +95,7 @@ def login(request):
|
||||
)
|
||||
if user is None or user.is_disabled:
|
||||
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
team = get_current_team(user)
|
||||
team = current_team_or_none(user)
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
record_login_session(request, user)
|
||||
return Response(auth_payload(user, team, token))
|
||||
@@ -110,11 +121,11 @@ def me(request):
|
||||
if email:
|
||||
user.email = email
|
||||
user.save(update_fields=["first_name", "phone", "email"])
|
||||
team = get_current_team(user)
|
||||
team = current_team_or_none(user)
|
||||
return Response(
|
||||
{
|
||||
"user": UserSerializer(user).data,
|
||||
"team": TeamSerializer(team).data,
|
||||
"team": TeamSerializer(team).data if team is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user