feat: RBAC Phase 1 — consolidate user roles into 4-value enum
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Consolidate User.role (2-value: admin/store) + User.is_super_admin (boolean)
into a single 4-value UserRole enum: super_admin, platform_admin,
merchant_owner, store_member. Drop stale StoreUser.user_type column.
Fix role="user" bug in merchant creation.

Key changes:
- Expand UserRole enum from 2 to 4 values with computed properties
  (is_admin, is_super_admin, is_platform_admin, is_merchant_owner, is_store_user)
- Add Alembic migration (tenancy_003) for data migration + column drops
- Remove is_super_admin from JWT token payload
- Update all auth dependencies, services, routes, templates, JS, and tests
- Update all RBAC documentation

66 files changed, 1219 unit tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 22:44:29 +01:00
parent ef21d47533
commit 1dcb0e6c33
67 changed files with 874 additions and 616 deletions

View File

@@ -155,23 +155,36 @@ StorePermissions.IMPORTS_CANCEL
---
## User Role Properties
```python
# Check if super admin (computed: role == "super_admin")
user.is_super_admin # bool
# Check if any admin (computed: role in ("super_admin", "platform_admin"))
user.is_admin # bool
# Check if platform admin (computed: role == "platform_admin")
user.is_platform_admin # bool
# Check if merchant owner (computed: role == "merchant_owner")
user.is_merchant_owner # bool
# Check if store-level user (computed: role in ("merchant_owner", "store_member"))
user.is_store_user # bool
```
## User Helper Methods
```python
# Check if admin
user.is_admin # bool
# Check if store
user.is_store # bool
# Check store ownership
# Check store ownership (via Merchant.owner_user_id)
user.is_owner_of(store_id) # bool
# Check store membership
user.is_member_of(store_id) # bool
# Get role in store
user.get_store_role(store_id) # str: "owner" | "member" | None
user.get_store_role(store_id) # str: "owner" | role name | None
# Check specific permission
user.has_store_permission(store_id, "products.create") # bool
@@ -182,7 +195,7 @@ user.has_store_permission(store_id, "products.create") # bool
## StoreUser Helper Methods
```python
# Check if owner
# Check if owner (derived from User.role and Merchant.owner_user_id)
store_user.is_owner # bool
# Check if team member
@@ -331,7 +344,8 @@ async function getPermissions() {
### Unit Test
```python
def test_owner_has_all_permissions():
store_user = create_store_user(user_type="owner")
user = create_user(role="merchant_owner")
store_user = create_store_user(user=user, store=store)
assert store_user.has_permission("products.create")
assert store_user.has_permission("team.invite")
```
@@ -423,8 +437,9 @@ token = "eyJ0eXAi..."
decoded = jwt.decode(token, verify=False)
print(f"User ID: {decoded['sub']}")
print(f"Username: {decoded['username']}")
print(f"Role: {decoded['role']}")
print(f"Role: {decoded['role']}") # super_admin, platform_admin, merchant_owner, or store_member
print(f"Expires: {decoded['exp']}")
# Note: is_super_admin is no longer in JWT tokens; derive from role == "super_admin"
```
### Check Cookie

View File

@@ -8,56 +8,76 @@ The store dashboard implements a **Role-Based Access Control (RBAC)** system tha
## User Types
### 1. Store Owner
### 1. Merchant Owner
**Who:** The user who created the store account.
**Who:** The user who created/owns the store account.
**Characteristics:**
- Has **ALL permissions** automatically (no role needed)
- Cannot be removed or have permissions restricted
- Can invite team members
- Can create and manage roles
- Identified by `StoreUser.user_type = "owner"`
- Linked via `Store.owner_user_id → User.id`
- Identified by `User.role = "merchant_owner"` and `Merchant.owner_user_id`
- `User.is_merchant_owner` property returns `True`
- `User.is_store_user` property returns `True`
- Ownership checked via `User.is_owner_of(store_id)`
**Database:**
```python
# User record for merchant owner
{
"id": 5,
"role": "merchant_owner", # Role on User model
}
# StoreUser record for owner
{
"store_id": 1,
"user_id": 5,
"user_type": "owner", # ✓ Owner
"role_id": None, # No role needed
"role_id": None, # No role needed (owner has all perms)
"is_active": True
}
# Merchant record links ownership
{
"owner_user_id": 5 # Links to User.id
}
```
**Note:** The `user_type` column was removed from StoreUser. Ownership is determined by `User.role == "merchant_owner"` and `Merchant.owner_user_id`, not by a field on StoreUser.
**Permissions:**
-**All 75 permissions** (complete access)
- See full list below
---
### 2. Team Members
### 2. Store Members (Team Members)
**Who:** Users invited by the store owner to help manage the store.
**Who:** Users invited by the merchant owner to help manage the store.
**Characteristics:**
- Have **limited permissions** based on assigned role
- Must be invited via email
- Invitation must be accepted before activation
- Can be assigned one of the pre-defined roles or custom role
- Identified by `StoreUser.user_type = "member"`
- Permissions come from `StoreUser.role_id → Role.permissions`
- Identified by `User.role = "store_member"`
- `User.is_store_user` property returns `True`
- Permissions come from `StoreUser.role_id -> Role.permissions`
**Database:**
```python
# User record for store member
{
"id": 7,
"role": "store_member", # Role on User model
}
# StoreUser record for team member
{
"store_id": 1,
"user_id": 7,
"user_type": "member", # ✓ Team member
"role_id": 3, # ✓ Role required
"role_id": 3, # Role required for permission lookup
"is_active": True,
"invitation_token": None, # Accepted
"invitation_accepted_at": "2024-11-15 10:30:00"
@@ -463,8 +483,7 @@ CREATE TABLE store_users (
id SERIAL PRIMARY KEY,
store_id INTEGER NOT NULL REFERENCES stores(id),
user_id INTEGER NOT NULL REFERENCES users(id),
user_type VARCHAR NOT NULL, -- 'owner' or 'member'
role_id INTEGER REFERENCES roles(id), -- NULL for owners
role_id INTEGER REFERENCES roles(id), -- NULL for merchant owners
invited_by INTEGER REFERENCES users(id),
invitation_token VARCHAR,
invitation_sent_at TIMESTAMP,
@@ -473,6 +492,8 @@ CREATE TABLE store_users (
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Note: user_type column removed. Ownership determined by
-- User.role = 'merchant_owner' and Merchant.owner_user_id.
```
### Role Table
@@ -495,9 +516,11 @@ CREATE TABLE roles (
### 1. Invitation
```
Owner invites user → StoreUser created:
Merchant owner invites user:
→ User created with role="store_member"
→ StoreUser created:
{
"user_type": "member",
"role_id": 3, -- Assigned role
"is_active": False,
"invitation_token": "abc123...",
"invitation_sent_at": "2024-11-29 10:00:00",
@@ -642,21 +665,22 @@ HTTP 403 Forbidden
## Summary
### Owner vs Team Member
### Merchant Owner vs Store Member
| Feature | Owner | Team Member |
| Feature | Merchant Owner (`role="merchant_owner"`) | Store Member (`role="store_member"`) |
|---------|-------|-------------|
| **Permissions** | All 75 (automatic) | Based on role (0-75) |
| **Role Required** | No | Yes |
| **Can Be Removed** | No | Yes |
| **Team Management** | Yes | No |
| **Critical Settings** | Yes | No (usually) |
| **Team Management** | Yes | No |
| **Critical Settings** | Yes | No (usually) |
| **Invitation Required** | No (creates store) | Yes |
| **Ownership Determined By** | `Merchant.owner_user_id` | N/A |
### Permission Hierarchy
```
Owner (75 permissions)
Merchant Owner (75 permissions)
└─ Manager (43 permissions)
└─ Staff (10 permissions)
└─ Support (6 permissions)