Skip to content
Open
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
26 changes: 25 additions & 1 deletion src/backend/src/common/workflow_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,34 @@ def _resolve_role_to_users(
- '<uuid>' → app role UUID
- 'user@email.com' → direct email
- Legacy aliases like 'domain_owners', 'admins', etc.
- **Comma-separated list of any of the above** → recurse on each
segment, flatten results. This is what the Workflow Designer's
"Custom principals" toggle emits: a role/literal token from the
Select plus any users / groups picked from the PrincipalPicker.
"""
from src.db_models.settings import AppRoleDb
from src.db_models.business_roles import BusinessRoleDb
from src.db_models.business_owners import BusinessOwnerDb

# Recursion gate for the mixed-shape list. We check for ``,`` first
# so each segment hits the rest of this function in isolation and
# role tokens / aliases / business: prefixes still resolve.
if ',' in role_spec:
out: List[tuple] = []
seen: set = set()
for segment in role_spec.split(','):
seg = segment.strip()
if not seg:
continue
for identifier, role_uuid in _resolve_role_to_users(db, seg, context):
if not identifier:
continue
key = (identifier, role_uuid)
if key not in seen:
seen.add(key)
out.append((identifier, role_uuid))
return out

# Map shorthand names to role names (legacy support)
role_aliases = {
'domain_owners': 'DomainOwner',
Expand Down Expand Up @@ -259,7 +282,8 @@ def _resolve_role_to_users(
return [(role_name, role.id if role else None)]

if '@' in role_spec:
return [(e.strip(), None) for e in role_spec.split(',')]
# Pre-comma-split single email -- treat as a direct recipient.
return [(role_spec, None)]

# Business role: prefixed with "business:"
if role_spec.startswith('business:'):
Expand Down
125 changes: 125 additions & 0 deletions src/backend/src/tests/unit/test_resolve_role_to_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Unit tests for ``_resolve_role_to_users``.

Pins the existing single-token behaviour and locks down the new
comma-split / recurse path the Workflow Designer's "Custom principals"
toggle relies on.
"""

from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from src.common.workflow_executor import _resolve_role_to_users


def _ctx(*, user_email="alice@example.com", entity=None, entity_type=None, entity_id=None):
return SimpleNamespace(
user_email=user_email,
entity=entity or {},
entity_type=entity_type,
entity_id=entity_id,
entity_name=None,
)


@pytest.fixture
def db_with_no_roles():
"""A DB session whose AppRole queries return nothing.

Sufficient for tests that only exercise the single-token
requester/owner/email/literal branches plus the new comma-split.
"""

db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
db.query.return_value.all.return_value = []
return db


class TestSingleTokenBehaviour:
def test_requester_returns_user_email(self, db_with_no_roles):
out = _resolve_role_to_users(db_with_no_roles, "requester", _ctx(user_email="r@x"))
assert out == [("r@x", None)]

def test_requester_returns_empty_when_no_email(self, db_with_no_roles):
out = _resolve_role_to_users(db_with_no_roles, "requester", _ctx(user_email=None))
assert out == []

def test_owner_pulls_from_entity(self, db_with_no_roles):
out = _resolve_role_to_users(
db_with_no_roles, "owner", _ctx(entity={"owner": "o@x"}),
)
assert out == [("o@x", None)]

def test_single_email_returns_as_literal(self, db_with_no_roles):
out = _resolve_role_to_users(db_with_no_roles, "alice@x.com", _ctx())
assert out == [("alice@x.com", None)]

def test_unknown_role_token_falls_back_to_literal(self, db_with_no_roles):
out = _resolve_role_to_users(db_with_no_roles, "Producers", _ctx())
# Unrecognised non-email token -- returned as literal so the
# downstream notification fan-out can still address it (e.g.
# group name).
assert out == [("Producers", None)]


class TestCommaSplit:
def test_mixed_role_email_group(self, db_with_no_roles):
# The Workflow Designer's "Custom principals" toggle emits
# something like this: a role-literal first, then any picked
# emails / group names.
out = _resolve_role_to_users(
db_with_no_roles,
"owner,alice@example.com,Producers",
_ctx(entity={"owner": "o@x"}),
)
ids = [identifier for identifier, _ in out]
assert ids == ["o@x", "alice@example.com", "Producers"]

def test_dedupes_repeats(self, db_with_no_roles):
out = _resolve_role_to_users(
db_with_no_roles,
"alice@x,alice@x,Producers,Producers",
_ctx(),
)
ids = [identifier for identifier, _ in out]
assert ids == ["alice@x", "Producers"]

def test_strips_whitespace_and_skips_blanks(self, db_with_no_roles):
out = _resolve_role_to_users(
db_with_no_roles,
" alice@x , , Producers ,",
_ctx(),
)
ids = [identifier for identifier, _ in out]
assert ids == ["alice@x", "Producers"]

def test_requester_token_inside_list_expands(self, db_with_no_roles):
# The role select can still drop ``requester`` in the string;
# the comma-split path must let it expand to the user email.
out = _resolve_role_to_users(
db_with_no_roles,
"requester,Producers",
_ctx(user_email="r@x"),
)
ids = [identifier for identifier, _ in out]
assert ids == ["r@x", "Producers"]

def test_owner_token_inside_list_expands(self, db_with_no_roles):
out = _resolve_role_to_users(
db_with_no_roles,
"owner,Producers",
_ctx(entity={"owner": "o@x"}),
)
ids = [identifier for identifier, _ in out]
assert ids == ["o@x", "Producers"]

def test_single_email_no_longer_splits_on_absence_of_comma(self, db_with_no_roles):
# The legacy comma-split-only-when-'@'-present path is gone;
# confirm a lone email still returns one tuple, not zero.
out = _resolve_role_to_users(db_with_no_roles, "alice@example.com", _ctx())
assert out == [("alice@example.com", None)]

def test_empty_list_returns_empty(self, db_with_no_roles):
assert _resolve_role_to_users(db_with_no_roles, ",,,", _ctx()) == []
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Textarea } from '@/components/ui/textarea'
import InferFromAssetDialog from './infer-from-asset-dialog'
import type { InferredSchemaObject } from './infer-from-asset-dialog'
import BusinessConceptsDisplay from '@/components/business-concepts/business-concepts-display'
import { PrincipalPicker } from '@/components/common/principal-picker'
import { useDomains } from '@/hooks/use-domains'
import { useToast } from '@/hooks/use-toast'

Expand Down Expand Up @@ -108,6 +109,25 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
const [descriptionPurpose, setDescriptionPurpose] = useState(initial?.descriptionPurpose || '')
const [descriptionLimitations, setDescriptionLimitations] = useState(initial?.descriptionLimitations || '')

// Stakeholders / access groups / support contacts wired up in Phase 4
// (PRD #335). Previously these step-4 fields rendered but their values
// were dropped at submit time; they now flow into the wizard payload.
const [consumers, setConsumers] = useState<string[]>(
(initial as { consumers?: string[] } | undefined)?.consumers ?? [],
)
const [subjectMatterExperts, setSubjectMatterExperts] = useState<string[]>(
(initial as { subjectMatterExperts?: string[] } | undefined)?.subjectMatterExperts ?? [],
)
const [readGroups, setReadGroups] = useState<string[]>(
(initial as { readGroups?: string[] } | undefined)?.readGroups ?? [],
)
const [writeGroups, setWriteGroups] = useState<string[]>(
(initial as { writeGroups?: string[] } | undefined)?.writeGroups ?? [],
)
const [primarySupportEmail, setPrimarySupportEmail] = useState<string>(
(initial as { primarySupportEmail?: string } | undefined)?.primarySupportEmail ?? '',
)

type Column = {
name: string;
physicalType?: string;
Expand Down Expand Up @@ -372,6 +392,12 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
tenant,
dataProduct,
description: { usage: descriptionUsage, purpose: descriptionPurpose, limitations: descriptionLimitations },
// Phase 4 stakeholder / access / support fields — previously
// rendered but unwired in the wizard.
consumers,
subjectMatterExperts,
accessControl: { readGroups, writeGroups },
support: { primaryEmail: primarySupportEmail },
// Include contract-level semantic assignments as authoritativeDefinitions
authoritativeDefinitions: convertSemanticConcepts(contractSemanticConcepts),
schema: schemaObjects.map((o) => ({
Expand Down Expand Up @@ -453,6 +479,12 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
tenant,
dataProduct,
description: { usage: descriptionUsage, purpose: descriptionPurpose, limitations: descriptionLimitations },
// Phase 4 stakeholder / access / support fields — previously
// rendered but unwired in the wizard.
consumers,
subjectMatterExperts,
accessControl: { readGroups, writeGroups },
support: { primaryEmail: primarySupportEmail },
// Include contract-level semantic assignments as authoritativeDefinitions
authoritativeDefinitions: convertSemanticConcepts(contractSemanticConcepts),
schema: schemaObjects.map((o) => ({
Expand Down Expand Up @@ -599,13 +631,16 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
</div>
<div>
<Label htmlFor="dc-owner" className="text-sm font-medium">Contract Owner</Label>
<Input
id="dc-owner"
value={owner}
onChange={(e) => setOwner(e.target.value)}
placeholder="e.g., data-team@company.com"
className="mt-1"
/>
<div className="mt-1">
<PrincipalPicker
id="dc-owner"
accepts={['user']}
value={owner || null}
onChange={(next) => setOwner(next ?? '')}
placeholder="e.g., data-team@company.com"
aria-label="Contract Owner"
/>
</div>
</div>
</div>

Expand Down Expand Up @@ -1535,28 +1570,32 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
</div>

<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="font-medium text-sm">Data Consumers</div>
<Button variant="outline" size="sm">+ Add</Button>
</div>
<div className="space-y-2">
<Input placeholder="consumer-team@company.com" className="text-sm" />
<div className="text-xs text-muted-foreground">
Add stakeholders who will consume this data
</div>
<div className="font-medium text-sm mb-3">Data Consumers</div>
<PrincipalPicker
multiple
accepts={['user', 'group']}
value={consumers}
onChange={setConsumers}
placeholder="consumer-team@company.com"
aria-label="Data Consumers"
/>
<div className="text-xs text-muted-foreground mt-2">
Stakeholders who will consume this data.
</div>
</div>

<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="font-medium text-sm">Subject Matter Experts</div>
<Button variant="outline" size="sm">+ Add</Button>
</div>
<div className="space-y-2">
<Input placeholder="expert@company.com" className="text-sm" />
<div className="text-xs text-muted-foreground">
Domain experts for business context and validation
</div>
<div className="font-medium text-sm mb-3">Subject Matter Experts</div>
<PrincipalPicker
multiple
accepts={['user']}
value={subjectMatterExperts}
onChange={setSubjectMatterExperts}
placeholder="expert@company.com"
aria-label="Subject Matter Experts"
/>
<div className="text-xs text-muted-foreground mt-2">
Domain experts for business context and validation.
</div>
</div>
</div>
Expand All @@ -1569,19 +1608,26 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
<div className="space-y-3">
<div className="p-4 border rounded-lg">
<div className="font-medium text-sm mb-3">Read Access</div>
<div className="space-y-2">
<Input placeholder="data-consumers-group" className="text-sm" />
<Input placeholder="analytics-team" className="text-sm" />
<Button variant="ghost" size="sm" className="text-primary">+ Add Group</Button>
</div>
<PrincipalPicker
multiple
accepts={['group']}
value={readGroups}
onChange={setReadGroups}
placeholder="data-consumers-group"
aria-label="Read access groups"
/>
</div>

<div className="p-4 border rounded-lg">
<div className="font-medium text-sm mb-3">Write Access</div>
<div className="space-y-2">
<Input placeholder="data-engineers-group" className="text-sm" />
<Button variant="ghost" size="sm" className="text-primary">+ Add Group</Button>
</div>
<PrincipalPicker
multiple
accepts={['group']}
value={writeGroups}
onChange={setWriteGroups}
placeholder="data-engineers-group"
aria-label="Write access groups"
/>
</div>

<div className="p-4 border rounded-lg">
Expand Down Expand Up @@ -1635,7 +1681,15 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div>
<Label className="text-sm font-medium">Primary Support Email</Label>
<Input placeholder="data-support@company.com" className="mt-1" />
<div className="mt-1">
<PrincipalPicker
accepts={['user']}
value={primarySupportEmail || null}
onChange={(next) => setPrimarySupportEmail(next ?? '')}
placeholder="data-support@company.com"
aria-label="Primary Support Email"
/>
</div>
</div>
<div>
<Label className="text-sm font-medium">Slack Channel</Label>
Expand Down
Loading