Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ const TaxonomyHistory = ({ taxonomyKey }: { taxonomyKey: string }) => {
<Tooltip title={formattedDate}>
{`${sentenceCase(distance)}`}
</Tooltip>
{item.user_id ? <span> by {item.user_id}</span> : null}
{(item.user_id ?? item.client_id) ? (
<span> by {item.user_id ?? item.client_id}</span>
) : null}
</>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ export type EventAuditResponse = {
* User Id
*/
user_id?: string | null;
client_id?: string | null;
};
1 change: 0 additions & 1 deletion clients/privacy-center/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""add client id columns to user submitted data

Revision ID: d76443a8a2e3
Revises: 6a42f48c23dd
Create Date: 2026-04-09 08:38:43.724458

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'd76443a8a2e3'
down_revision = '6a42f48c23dd'
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"comment",
sa.Column(
"client_id",
sa.String(),
sa.ForeignKey("client.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column(
"attachment",
sa.Column(
"client_id",
sa.String(),
sa.ForeignKey("client.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column(
"event_audit",
sa.Column(
"client_id",
sa.String(),
sa.ForeignKey("client.id", ondelete="SET NULL"),
nullable=True,
),
)


def downgrade():
op.drop_column("event_audit", "client_id")
op.drop_column("attachment", "client_id")
op.drop_column("comment", "client_id")
11 changes: 11 additions & 0 deletions src/fides/api/models/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from fides.api.service.storage.util import AllowedFileType

if TYPE_CHECKING:
from fides.api.models.client import ClientDetail
from fides.api.models.comment import Comment
from fides.api.models.fides_user import FidesUser
from fides.api.models.privacy_request import PrivacyRequest
Expand Down Expand Up @@ -104,6 +105,9 @@ class Attachment(Base):
user_id = Column(
String, ForeignKey("fidesuser.id", ondelete="SET NULL"), nullable=True
)
client_id = Column(
String, ForeignKey("client.id", ondelete="SET NULL"), nullable=True
)
# Not all users in the system have a username, and users can be deleted.
# Store a non-normalized copy of username for these cases.
username = Column(String, nullable=True)
Expand All @@ -118,6 +122,13 @@ class Attachment(Base):
lazy="selectin",
uselist=False,
)
client: "ClientDetail" = relationship(
"ClientDetail",
lazy="selectin",
uselist=False,
foreign_keys=[client_id],
primaryjoin="Attachment.client_id == ClientDetail.id",
)

references = relationship(
"AttachmentReference",
Expand Down
11 changes: 11 additions & 0 deletions src/fides/api/models/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

if TYPE_CHECKING:
from fides.api.models.attachment import Attachment, AttachmentReference
from fides.api.models.client import ClientDetail
from fides.api.models.fides_user import FidesUser
from fides.api.models.privacy_request import PrivacyRequest

Expand Down Expand Up @@ -102,6 +103,9 @@ class Comment(Base):
ForeignKey("comment.id", name="comment_parent_id_fkey", ondelete="SET NULL"),
nullable=True,
)
client_id = Column(
String, ForeignKey("client.id", ondelete="SET NULL"), nullable=True
)
# Not all users in the system have a username, and users can be deleted.
# Store a non-normalized copy of username for these cases.
username = Column(String, nullable=True)
Expand Down Expand Up @@ -132,6 +136,13 @@ class Comment(Base):
lazy="selectin",
uselist=False,
)
client: "ClientDetail" = relationship(
"ClientDetail",
lazy="selectin",
uselist=False,
foreign_keys=[client_id],
primaryjoin="Comment.client_id == ClientDetail.id",
)

references = relationship(
"CommentReference",
Expand Down
17 changes: 16 additions & 1 deletion src/fides/api/models/event_audit.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"""EventAudit model and related enums for comprehensive audit logging."""

from enum import Enum as EnumType
from typing import TYPE_CHECKING

from sqlalchemy import Column, String, Text
from sqlalchemy import Column, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import relationship

from fides.api.db.base_class import Base
from fides.api.db.util import EnumColumn

if TYPE_CHECKING:
from fides.api.models.client import ClientDetail


class EventAuditType(str, EnumType):
"""Hierarchical event types for audit logging - variable depth as needed."""
Expand Down Expand Up @@ -90,6 +95,16 @@ def __tablename__(cls) -> str:
# Uses EventAuditType values but left as String to avoid future migrations
event_type = Column(String, index=True, nullable=False)
user_id = Column(String, nullable=True, index=True)
client_id = Column(
String, ForeignKey("client.id", ondelete="SET NULL"), nullable=True, index=True
)
client: "ClientDetail" = relationship(
"ClientDetail",
lazy="selectin",
uselist=False,
foreign_keys=[client_id],
primaryjoin="EventAudit.client_id == ClientDetail.id",
)

# Resource information
resource_type = Column(String, nullable=True, index=True)
Expand Down
3 changes: 1 addition & 2 deletions src/fides/api/models/system_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def __tablename__(self) -> str:

@property
def edited_by(self) -> Optional[str]:
"""Derive the username from the user_id"""
"""Derive the display name from user_id (or client_id once that column exists)."""
if not self.user_id:
return None

Expand All @@ -44,5 +44,4 @@ def edited_by(self) -> Optional[str]:

db = Session.object_session(self)
user: Optional[FidesUser] = FidesUser.get_by(db, field="id", value=self.user_id)

return user.username if user else self.user_id
42 changes: 21 additions & 21 deletions src/fides/api/oauth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from fides.api.models.privacy_request import RequestTask
from fides.api.oauth.jwt import decrypt_jwe
from fides.api.oauth.roles import ROLES_TO_SCOPES_MAPPING, get_scopes_from_roles
from fides.api.request_context import set_user_id
from fides.api.request_context import set_client_id, set_user_id
from fides.api.schemas.external_https import (
DownloadTokenJWE,
RequestTaskJWE,
Expand Down Expand Up @@ -542,6 +542,24 @@ async def verify_oauth_client_async(
return client


def _populate_request_context_from_client(client: ClientDetail) -> None:
"""Set user_id or client_id on the request context based on the authenticated actor.

Priority order:
1. Linked FidesUser → set user_id (human user acting via their personal client)
2. Root client → set user_id to root client id (special system actor)
3. API client → set client_id (non-user-linked OAuth client)
"""
ctx_user_id = client.user_id
if not ctx_user_id and client.id == CONFIG.security.oauth_root_client_id:
ctx_user_id = CONFIG.security.oauth_root_client_id

if ctx_user_id:
set_user_id(ctx_user_id)
else:
set_client_id(client.id)


def extract_token_and_load_client(
authorization: str = Security(oauth2_scheme),
db: Session = Depends(get_db),
Expand Down Expand Up @@ -596,16 +614,7 @@ def extract_token_and_load_client(
logger.debug("Auth token issued before latest password reset.")
raise AuthorizationError(detail="Not Authorized for this action")

# Populate request-scoped context with the authenticated user identifier.
# Prefer the linked user_id; fall back to the client id when this is the
# special root client (which has no associated FidesUser row).
ctx_user_id = client.user_id
if not ctx_user_id and client.id == CONFIG.security.oauth_root_client_id:
ctx_user_id = CONFIG.security.oauth_root_client_id

if ctx_user_id:
set_user_id(ctx_user_id)

_populate_request_context_from_client(client)
return token_data, client


Expand Down Expand Up @@ -678,16 +687,7 @@ async def extract_token_and_load_client_async(
logger.debug("Auth token issued before latest password reset.")
raise AuthorizationError(detail="Not Authorized for this action")

# Populate request-scoped context with the authenticated user identifier.
# Prefer the linked user_id; fall back to the client id when this is the
# special root client (which has no associated FidesUser row).
ctx_user_id = client.user_id
if not ctx_user_id and client.id == CONFIG.security.oauth_root_client_id:
ctx_user_id = CONFIG.security.oauth_root_client_id

if ctx_user_id:
set_user_id(ctx_user_id)

_populate_request_context_from_client(client)
return token_data, client


Expand Down
18 changes: 18 additions & 0 deletions src/fides/api/request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
from typing import Any, Optional

__all__ = [
"get_client_id",
"get_request_id",
"get_user_id",
"reset_request_context",
"set_client_id",
"set_request_id",
"set_user_id",
]
Expand All @@ -32,6 +34,7 @@
class RequestContext:
user_id: Optional[str] = None
request_id: Optional[str] = None
client_id: Optional[str] = None


# A single ContextVar holding the current request context.
Expand Down Expand Up @@ -95,3 +98,18 @@ def get_request_id() -> Optional[str]:
def set_request_id(request_id: Optional[str] = None) -> None:
"""Set or clear the request_id in the current request context."""
set_request_context(request_id=request_id)


def get_client_id() -> Optional[str]:
"""Return the client_id from the current request context.

Set when the authenticated actor is a non-user-linked API client.
Mutually exclusive with user_id — only one will be non-None per request.
"""
ctx = get_request_context()
return ctx.client_id


def set_client_id(client_id: str) -> None:
"""Set the client_id in the current request context."""
set_request_context(client_id=client_id)
6 changes: 6 additions & 0 deletions src/fides/service/attachment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from loguru import logger
from sqlalchemy.orm import Session

from fides.api.request_context import get_client_id
from fides.api.models.attachment import (
Attachment,
AttachmentReference,
Expand Down Expand Up @@ -209,6 +210,11 @@ def create_and_upload(
"""
db = self._require_db()

# Populate client_id from request context if not already set by the caller.
# This covers API client actors whose user_id is None.
if "client_id" not in data:
data = {**data, "client_id": get_client_id()}

# Create the attachment record using internal _create_record to avoid
# triggering the deprecation warning on the public create() method
attachment = Attachment._create_record(db=db, data=data, check_name=check_name)
Expand Down
63 changes: 63 additions & 0 deletions src/fides/service/attribution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Service for resolving actor display names from user_id or client_id."""

from typing import Optional

from sqlalchemy.orm import Session

from fides.api.models.fides_user import FidesUser
from fides.config import get_config

CONFIG = get_config()


class AttributionService:
"""Service for resolving actor display names.

Translates user_id or client_id into a human-readable display name.
Exactly one of user_id or client_id will be set per request — they are
mutually exclusive (human user XOR API client).
"""

def __init__(self, db: Session):
self.db = db

def get_actor_display_name(
self,
*,
user_id: Optional[str],
client_id: Optional[str],
fallback: Optional[str] = None,
) -> Optional[str]:
"""Resolve a human-readable display name for the actor that performed an action.

Args:
user_id: The user ID of the actor, if a human user.
client_id: The client ID of the actor, if an API client.
fallback: Value to return when neither id resolves to a name.

Returns:
Display name string, or fallback if not resolvable.
"""
if user_id:
if user_id == CONFIG.security.oauth_root_client_id:
return CONFIG.security.root_username

user: Optional[FidesUser] = FidesUser.get_by(
self.db, field="id", value=user_id
)
return user.username if user else fallback

if client_id:
# Import here to avoid circular imports — ClientDetail lives in oauth models
from fides.api.models.client import ClientDetail # noqa: PLC0415

client: Optional[ClientDetail] = (
self.db.query(ClientDetail)
.filter(ClientDetail.id == client_id)
.first()
)
if client:
return client.name or client_id
return client_id

return fallback
Loading
Loading