Skip to content
Merged

Dev #694

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions backend/common/middleware/get_company.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ def process_request(self, request):
request.profile = None
request.org = None

# Personal Access Token (agent / MCP) — resolve here so org context is
# set before RequireOrgContext runs (DRF auth runs too late for that).
# MUST come before the JWT branch so a PAT bearer is never handed to
# the JWT decoder.
raw_pat = self._extract_pat(request)
if raw_pat:
self._process_pat_auth(request, raw_pat)
return

# Try JWT token first (primary authentication)
if request.headers.get("Authorization"):
self._process_jwt_auth(request)
Expand All @@ -53,6 +62,42 @@ def process_request(self, request):
self._process_api_key_auth(request, api_key)
return

def _extract_pat(self, request):
"""Return a bcrm_pat_-prefixed token from the request, else None.

Reuses the same extractor as the DRF auth class so the detection logic
(Authorization: Bearer … or the Token header) lives in one place.
"""
from common.pat_auth import _extract_raw

return _extract_raw(request)

def _process_pat_auth(self, request, raw):
"""Resolve a PAT and set org context, mirroring the JWT/org-key paths.

On an invalid/revoked/expired PAT we leave request.org unset so that
RequireOrgContext returns a clean 403 (the same denial the org-key path
produces for an unknown key once that exception surfaces). We swallow
AuthenticationFailed here rather than re-raising so the request is
denied cleanly downstream instead of 500-ing inside middleware.
"""
from rest_framework.exceptions import AuthenticationFailed

from common.pat_auth import resolve_valid_pat

try:
pat = resolve_valid_pat(raw)
except AuthenticationFailed:
# Leave org unset → RequireOrgContext denies with 403. The DRF
# PATAuthentication class will also raise on this token, but the
# middleware-level denial happens first.
return
request.profile = pat.profile
request.org = pat.org
request.META["org"] = str(pat.org.id)
request.META["mcp_token_id"] = str(pat.id)
request._pat = pat

def _process_jwt_auth(self, request):
"""
Process JWT authentication and extract org context from token.
Expand Down
39 changes: 39 additions & 0 deletions backend/common/migrations/0027_personalaccesstoken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 6.0.5 on 2026-06-01 03:47

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('common', '0026_add_user_name'),
]

operations = [
migrations.CreateModel(
name='PersonalAccessToken',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255)),
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
('token_prefix', models.CharField(max_length=20)),
('scopes', models.JSONField(blank=True, default=list)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('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')),
('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')),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='common.profile')),
('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')),
],
options={
'db_table': 'personal_access_token',
'indexes': [models.Index(fields=['org', '-created_at'], name='personal_ac_org_id_cc57f9_idx')],
},
),
]
66 changes: 65 additions & 1 deletion backend/common/models.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import binascii
import hashlib
import os
import secrets
import time
import uuid

from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
from django.utils.timesince import timesince
from django.utils.translation import gettext_lazy as _
from common.base import BaseModel
from common.base import BaseModel, BaseOrgModel
from common.utils import (
COUNTRIES,
CURRENCY_CODES,
Expand Down Expand Up @@ -881,5 +884,66 @@ def __str__(self):
return f"{self.target_model}.{self.key} ({self.label})"


def generate_pat_raw():
"""Return a new raw personal access token string."""
return f"bcrm_pat_{secrets.token_urlsafe(32)}"


class PersonalAccessToken(BaseOrgModel):
"""
Per-user token for programmatic/agent (MCP) access.

The agent authenticates AS `profile` and inherits that user's role,
org and RLS scope. The raw token is shown ONCE at creation and only
its SHA-256 hash is stored.
"""

profile = models.ForeignKey(
"common.Profile",
on_delete=models.CASCADE,
related_name="access_tokens",
)
name = models.CharField(max_length=255)
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
token_prefix = models.CharField(max_length=20)
# NOTE: scopes are stored for forward-compatibility but are NOT enforced in
# Phase 1 — a token always inherits the owning profile's full role/permissions.
# Do not treat `scopes` as a trust boundary until enforcement lands.
scopes = models.JSONField(default=list, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
last_used_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True)

class Meta:
db_table = "personal_access_token"
indexes = [models.Index(fields=["org", "-created_at"])]

@staticmethod
def hash_token(raw):
return hashlib.sha256(raw.encode()).hexdigest()

@classmethod
def generate(cls, profile, name, scopes=None, expires_at=None):
raw = generate_pat_raw()
pat = cls.objects.create(
org=profile.org,
profile=profile,
name=name,
token_hash=cls.hash_token(raw),
token_prefix=raw[:13],
scopes=scopes or [],
expires_at=expires_at,
created_by=profile.user,
)
return raw, pat

def is_valid(self):
if self.revoked_at is not None:
return False
if self.expires_at is not None and self.expires_at <= timezone.now():
return False
return True


# Import SecurityAuditLog so Django discovers it for migrations
from common.audit_log import SecurityAuditLog # noqa: F401,E402 # pylint: disable=unused-import
91 changes: 91 additions & 0 deletions backend/common/pat_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import logging

from django.utils import timezone
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed

from common.models import PersonalAccessToken

logger = logging.getLogger(__name__)

PAT_PREFIX = "bcrm_pat_"


def _extract_raw(request):
"""Pull a bcrm_pat_ token from Authorization: Bearer or Token header."""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
candidate = auth[len("Bearer "):].strip()
if candidate.startswith(PAT_PREFIX):
return candidate
token = request.headers.get("Token", "")
if token.startswith(PAT_PREFIX):
return token
return None


def resolve_valid_pat(raw):
"""Look up and validate a raw PAT.

Returns the PersonalAccessToken (with profile/user/org pre-fetched) or
raises AuthenticationFailed. Shared by the GetProfileAndOrg middleware
(which must set org context before RequireOrgContext runs) and the DRF
PATAuthentication class, so the lookup/validation logic lives in one place.
"""
try:
pat = PersonalAccessToken.objects.select_related(
"profile", "profile__user", "org"
).get(token_hash=PersonalAccessToken.hash_token(raw))
except PersonalAccessToken.DoesNotExist as exc:
logger.warning("Invalid PAT attempted")
raise AuthenticationFailed("Invalid token") from exc
if not pat.is_valid():
raise AuthenticationFailed("Token revoked or expired")
if not pat.profile.is_active or not pat.org.is_active:
raise AuthenticationFailed("Token owner or org is inactive")
return pat


class PATAuthentication(BaseAuthentication):
"""Authenticate an agent AS the token's owning Profile (inherits role+org)."""

def authenticate(self, request):
raw = _extract_raw(request)
if not raw:
return None # Not a PAT — let JWT / org-key auth handle it.

# The GetProfileAndOrg middleware resolves the PAT first (so org
# context is set before RequireOrgContext runs) and stashes it on the
# request. Reuse it to avoid a second DB lookup and a double
# last_used_at write. Fall back to resolving here for any code path
# that bypasses the middleware (e.g. RequestFactory unit tests).
pat = getattr(request, "_pat", None)
if pat is None:
pat = resolve_valid_pat(raw)

profile = pat.profile

request.profile = profile
request.org = pat.org
request.META["org"] = str(pat.org.id)
request.META["mcp_token_id"] = str(pat.id)

now = timezone.now()
if pat.last_used_at is None or (now - pat.last_used_at).total_seconds() > 60:
PersonalAccessToken.objects.filter(pk=pat.pk).update(last_used_at=now)
pat.last_used_at = now

return (profile.user, pat)


class PATAuthenticationScheme(OpenApiAuthenticationExtension):
target_class = "common.pat_auth.PATAuthentication"
name = "PersonalAccessToken"

def get_security_definition(self, auto_schema):
return {
"type": "http",
"scheme": "bearer",
"description": "Personal access token (bcrm_pat_…) for agent/MCP access",
}
5 changes: 5 additions & 0 deletions backend/common/rls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@
# Approval workflows (Tier 3 approvals).
"approval_rule",
"approval",
# MCP / programmatic access
# NOTE: personal_access_token is intentionally NOT RLS-protected — it is an
# auth-bootstrap table (looked up by token_hash before any tenant context
# exists), mirroring the Org table. Isolation for token management is enforced
# by explicit org+profile filters in common/views/pat_views.py.
]

# Centralized RLS configuration
Expand Down
47 changes: 47 additions & 0 deletions backend/common/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Document,
Notification,
Org,
PersonalAccessToken,
Profile,
Tags,
Teams,
Expand Down Expand Up @@ -890,3 +891,49 @@ class Meta:
"description",
"users",
)


class PersonalAccessTokenListSerializer(serializers.ModelSerializer):
class Meta:
model = PersonalAccessToken
fields = (
"id",
"name",
"token_prefix",
"scopes",
"expires_at",
"last_used_at",
"created_at",
"revoked_at",
)
read_only_fields = fields


class PersonalAccessTokenCreateSerializer(serializers.ModelSerializer):
class Meta:
model = PersonalAccessToken
fields = ("name", "scopes", "expires_at")

def validate_name(self, value):
value = (value or "").strip()
if not value:
raise serializers.ValidationError("Name is required.")
if len(value) > 255:
raise serializers.ValidationError("Name too long (max 255).")
return value

def validate_scopes(self, value):
if value in (None, ""):
return []
if not isinstance(value, list) or not all(isinstance(s, str) for s in value):
raise serializers.ValidationError("scopes must be a list of strings.")
if len(value) > 32:
raise serializers.ValidationError("Too many scopes (max 32).")
return value

def validate_expires_at(self, value):
from django.utils import timezone

if value is not None and value <= timezone.now():
raise serializers.ValidationError("expires_at must be in the future.")
return value
Loading
Loading