Skip to content

Commit e320dfc

Browse files
authored
Merge pull request #694 from MicroPyramid/dev
Dev
2 parents 5f3bf75 + dbde120 commit e320dfc

32 files changed

Lines changed: 4998 additions & 2 deletions

backend/common/middleware/get_company.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ def process_request(self, request):
4242
request.profile = None
4343
request.org = None
4444

45+
# Personal Access Token (agent / MCP) — resolve here so org context is
46+
# set before RequireOrgContext runs (DRF auth runs too late for that).
47+
# MUST come before the JWT branch so a PAT bearer is never handed to
48+
# the JWT decoder.
49+
raw_pat = self._extract_pat(request)
50+
if raw_pat:
51+
self._process_pat_auth(request, raw_pat)
52+
return
53+
4554
# Try JWT token first (primary authentication)
4655
if request.headers.get("Authorization"):
4756
self._process_jwt_auth(request)
@@ -53,6 +62,42 @@ def process_request(self, request):
5362
self._process_api_key_auth(request, api_key)
5463
return
5564

65+
def _extract_pat(self, request):
66+
"""Return a bcrm_pat_-prefixed token from the request, else None.
67+
68+
Reuses the same extractor as the DRF auth class so the detection logic
69+
(Authorization: Bearer … or the Token header) lives in one place.
70+
"""
71+
from common.pat_auth import _extract_raw
72+
73+
return _extract_raw(request)
74+
75+
def _process_pat_auth(self, request, raw):
76+
"""Resolve a PAT and set org context, mirroring the JWT/org-key paths.
77+
78+
On an invalid/revoked/expired PAT we leave request.org unset so that
79+
RequireOrgContext returns a clean 403 (the same denial the org-key path
80+
produces for an unknown key once that exception surfaces). We swallow
81+
AuthenticationFailed here rather than re-raising so the request is
82+
denied cleanly downstream instead of 500-ing inside middleware.
83+
"""
84+
from rest_framework.exceptions import AuthenticationFailed
85+
86+
from common.pat_auth import resolve_valid_pat
87+
88+
try:
89+
pat = resolve_valid_pat(raw)
90+
except AuthenticationFailed:
91+
# Leave org unset → RequireOrgContext denies with 403. The DRF
92+
# PATAuthentication class will also raise on this token, but the
93+
# middleware-level denial happens first.
94+
return
95+
request.profile = pat.profile
96+
request.org = pat.org
97+
request.META["org"] = str(pat.org.id)
98+
request.META["mcp_token_id"] = str(pat.id)
99+
request._pat = pat
100+
56101
def _process_jwt_auth(self, request):
57102
"""
58103
Process JWT authentication and extract org context from token.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 6.0.5 on 2026-06-01 03:47
2+
3+
import django.db.models.deletion
4+
import uuid
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('common', '0026_add_user_name'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='PersonalAccessToken',
18+
fields=[
19+
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
20+
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
21+
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
22+
('name', models.CharField(max_length=255)),
23+
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
24+
('token_prefix', models.CharField(max_length=20)),
25+
('scopes', models.JSONField(blank=True, default=list)),
26+
('expires_at', models.DateTimeField(blank=True, null=True)),
27+
('last_used_at', models.DateTimeField(blank=True, null=True)),
28+
('revoked_at', models.DateTimeField(blank=True, null=True)),
29+
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
30+
('org', models.ForeignKey(help_text='Organization this record belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='common.org')),
31+
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='common.profile')),
32+
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
33+
],
34+
options={
35+
'db_table': 'personal_access_token',
36+
'indexes': [models.Index(fields=['org', '-created_at'], name='personal_ac_org_id_cc57f9_idx')],
37+
},
38+
),
39+
]

backend/common/models.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import binascii
2+
import hashlib
23
import os
4+
import secrets
35
import time
46
import uuid
57

68
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
79
from django.contrib.contenttypes.fields import GenericForeignKey
810
from django.contrib.contenttypes.models import ContentType
911
from django.db import models
12+
from django.utils import timezone
1013
from django.utils.text import slugify
1114
from django.utils.timesince import timesince
1215
from django.utils.translation import gettext_lazy as _
13-
from common.base import BaseModel
16+
from common.base import BaseModel, BaseOrgModel
1417
from common.utils import (
1518
COUNTRIES,
1619
CURRENCY_CODES,
@@ -881,5 +884,66 @@ def __str__(self):
881884
return f"{self.target_model}.{self.key} ({self.label})"
882885

883886

887+
def generate_pat_raw():
888+
"""Return a new raw personal access token string."""
889+
return f"bcrm_pat_{secrets.token_urlsafe(32)}"
890+
891+
892+
class PersonalAccessToken(BaseOrgModel):
893+
"""
894+
Per-user token for programmatic/agent (MCP) access.
895+
896+
The agent authenticates AS `profile` and inherits that user's role,
897+
org and RLS scope. The raw token is shown ONCE at creation and only
898+
its SHA-256 hash is stored.
899+
"""
900+
901+
profile = models.ForeignKey(
902+
"common.Profile",
903+
on_delete=models.CASCADE,
904+
related_name="access_tokens",
905+
)
906+
name = models.CharField(max_length=255)
907+
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
908+
token_prefix = models.CharField(max_length=20)
909+
# NOTE: scopes are stored for forward-compatibility but are NOT enforced in
910+
# Phase 1 — a token always inherits the owning profile's full role/permissions.
911+
# Do not treat `scopes` as a trust boundary until enforcement lands.
912+
scopes = models.JSONField(default=list, blank=True)
913+
expires_at = models.DateTimeField(null=True, blank=True)
914+
last_used_at = models.DateTimeField(null=True, blank=True)
915+
revoked_at = models.DateTimeField(null=True, blank=True)
916+
917+
class Meta:
918+
db_table = "personal_access_token"
919+
indexes = [models.Index(fields=["org", "-created_at"])]
920+
921+
@staticmethod
922+
def hash_token(raw):
923+
return hashlib.sha256(raw.encode()).hexdigest()
924+
925+
@classmethod
926+
def generate(cls, profile, name, scopes=None, expires_at=None):
927+
raw = generate_pat_raw()
928+
pat = cls.objects.create(
929+
org=profile.org,
930+
profile=profile,
931+
name=name,
932+
token_hash=cls.hash_token(raw),
933+
token_prefix=raw[:13],
934+
scopes=scopes or [],
935+
expires_at=expires_at,
936+
created_by=profile.user,
937+
)
938+
return raw, pat
939+
940+
def is_valid(self):
941+
if self.revoked_at is not None:
942+
return False
943+
if self.expires_at is not None and self.expires_at <= timezone.now():
944+
return False
945+
return True
946+
947+
884948
# Import SecurityAuditLog so Django discovers it for migrations
885949
from common.audit_log import SecurityAuditLog # noqa: F401,E402 # pylint: disable=unused-import

backend/common/pat_auth.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import logging
2+
3+
from django.utils import timezone
4+
from drf_spectacular.extensions import OpenApiAuthenticationExtension
5+
from rest_framework.authentication import BaseAuthentication
6+
from rest_framework.exceptions import AuthenticationFailed
7+
8+
from common.models import PersonalAccessToken
9+
10+
logger = logging.getLogger(__name__)
11+
12+
PAT_PREFIX = "bcrm_pat_"
13+
14+
15+
def _extract_raw(request):
16+
"""Pull a bcrm_pat_ token from Authorization: Bearer or Token header."""
17+
auth = request.headers.get("Authorization", "")
18+
if auth.startswith("Bearer "):
19+
candidate = auth[len("Bearer "):].strip()
20+
if candidate.startswith(PAT_PREFIX):
21+
return candidate
22+
token = request.headers.get("Token", "")
23+
if token.startswith(PAT_PREFIX):
24+
return token
25+
return None
26+
27+
28+
def resolve_valid_pat(raw):
29+
"""Look up and validate a raw PAT.
30+
31+
Returns the PersonalAccessToken (with profile/user/org pre-fetched) or
32+
raises AuthenticationFailed. Shared by the GetProfileAndOrg middleware
33+
(which must set org context before RequireOrgContext runs) and the DRF
34+
PATAuthentication class, so the lookup/validation logic lives in one place.
35+
"""
36+
try:
37+
pat = PersonalAccessToken.objects.select_related(
38+
"profile", "profile__user", "org"
39+
).get(token_hash=PersonalAccessToken.hash_token(raw))
40+
except PersonalAccessToken.DoesNotExist as exc:
41+
logger.warning("Invalid PAT attempted")
42+
raise AuthenticationFailed("Invalid token") from exc
43+
if not pat.is_valid():
44+
raise AuthenticationFailed("Token revoked or expired")
45+
if not pat.profile.is_active or not pat.org.is_active:
46+
raise AuthenticationFailed("Token owner or org is inactive")
47+
return pat
48+
49+
50+
class PATAuthentication(BaseAuthentication):
51+
"""Authenticate an agent AS the token's owning Profile (inherits role+org)."""
52+
53+
def authenticate(self, request):
54+
raw = _extract_raw(request)
55+
if not raw:
56+
return None # Not a PAT — let JWT / org-key auth handle it.
57+
58+
# The GetProfileAndOrg middleware resolves the PAT first (so org
59+
# context is set before RequireOrgContext runs) and stashes it on the
60+
# request. Reuse it to avoid a second DB lookup and a double
61+
# last_used_at write. Fall back to resolving here for any code path
62+
# that bypasses the middleware (e.g. RequestFactory unit tests).
63+
pat = getattr(request, "_pat", None)
64+
if pat is None:
65+
pat = resolve_valid_pat(raw)
66+
67+
profile = pat.profile
68+
69+
request.profile = profile
70+
request.org = pat.org
71+
request.META["org"] = str(pat.org.id)
72+
request.META["mcp_token_id"] = str(pat.id)
73+
74+
now = timezone.now()
75+
if pat.last_used_at is None or (now - pat.last_used_at).total_seconds() > 60:
76+
PersonalAccessToken.objects.filter(pk=pat.pk).update(last_used_at=now)
77+
pat.last_used_at = now
78+
79+
return (profile.user, pat)
80+
81+
82+
class PATAuthenticationScheme(OpenApiAuthenticationExtension):
83+
target_class = "common.pat_auth.PATAuthentication"
84+
name = "PersonalAccessToken"
85+
86+
def get_security_definition(self, auto_schema):
87+
return {
88+
"type": "http",
89+
"scheme": "bearer",
90+
"description": "Personal access token (bcrm_pat_…) for agent/MCP access",
91+
}

backend/common/rls/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@
100100
# Approval workflows (Tier 3 approvals).
101101
"approval_rule",
102102
"approval",
103+
# MCP / programmatic access
104+
# NOTE: personal_access_token is intentionally NOT RLS-protected — it is an
105+
# auth-bootstrap table (looked up by token_hash before any tenant context
106+
# exists), mirroring the Org table. Isolation for token management is enforced
107+
# by explicit org+profile filters in common/views/pat_views.py.
103108
]
104109

105110
# Centralized RLS configuration

backend/common/serializer.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Document,
2222
Notification,
2323
Org,
24+
PersonalAccessToken,
2425
Profile,
2526
Tags,
2627
Teams,
@@ -890,3 +891,49 @@ class Meta:
890891
"description",
891892
"users",
892893
)
894+
895+
896+
class PersonalAccessTokenListSerializer(serializers.ModelSerializer):
897+
class Meta:
898+
model = PersonalAccessToken
899+
fields = (
900+
"id",
901+
"name",
902+
"token_prefix",
903+
"scopes",
904+
"expires_at",
905+
"last_used_at",
906+
"created_at",
907+
"revoked_at",
908+
)
909+
read_only_fields = fields
910+
911+
912+
class PersonalAccessTokenCreateSerializer(serializers.ModelSerializer):
913+
class Meta:
914+
model = PersonalAccessToken
915+
fields = ("name", "scopes", "expires_at")
916+
917+
def validate_name(self, value):
918+
value = (value or "").strip()
919+
if not value:
920+
raise serializers.ValidationError("Name is required.")
921+
if len(value) > 255:
922+
raise serializers.ValidationError("Name too long (max 255).")
923+
return value
924+
925+
def validate_scopes(self, value):
926+
if value in (None, ""):
927+
return []
928+
if not isinstance(value, list) or not all(isinstance(s, str) for s in value):
929+
raise serializers.ValidationError("scopes must be a list of strings.")
930+
if len(value) > 32:
931+
raise serializers.ValidationError("Too many scopes (max 32).")
932+
return value
933+
934+
def validate_expires_at(self, value):
935+
from django.utils import timezone
936+
937+
if value is not None and value <= timezone.now():
938+
raise serializers.ValidationError("expires_at must be in the future.")
939+
return value

0 commit comments

Comments
 (0)