Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a919914
feat(admin): platform-admin gate + is_platform_admin in currentuserv2…
Veekshitha11 Jul 12, 2026
9d1c190
feat(admin): /admin/stats endpoint for the portal dashboard (#1254, M2)
Veekshitha11 Jul 12, 2026
220d6c8
feat(admin): Org.is_active + deactivation enforcement (#1254, M3)
Veekshitha11 Jul 12, 2026
918fa56
feat(admin): org list/create/edit/deactivate endpoints (#1254, M3)
Veekshitha11 Jul 12, 2026
4d7494b
feat(admin): per-org user active + org-parameterized invite/role/remo…
Veekshitha11 Jul 14, 2026
f8e13f8
feat(admin): Users-tab endpoints — invite, role, deactivate, remove, …
Veekshitha11 Jul 14, 2026
6ecf6ed
test(admin): SES dev fallback + end-to-end Week 1 admin lifecycle flo…
Veekshitha11 Jul 14, 2026
5222d79
fix(admin): reconcile M1–M4 with upstream — SET_NULL orphaning, migra…
Veekshitha11 Jul 14, 2026
730c5a5
refactor(admin): extract core/admin/admin_service.py from admin_api h…
Veekshitha11 Jul 17, 2026
18fcdcd
docs(admin): correct stale "cascade-deletes" docstrings to orphaning/…
Veekshitha11 Jul 17, 2026
44f76ff
fix(admin): drop fragile error-string match in put_admin_org_user_rol…
Veekshitha11 Jul 17, 2026
aa81d3a
feat(admin-portal): admin-session service — issue_admin_session (M1)
Veekshitha11 Jul 22, 2026
c53ad12
feat(admin-portal): admin token lifetimes + bad-creds guard (M1)
Veekshitha11 Jul 22, 2026
aaedf59
feat(admin-portal): AdminJwtAuthMiddleware + cookie-name extraction (M1)
Veekshitha11 Jul 22, 2026
2530c3f
feat(admin-portal): admin session endpoints + router-level auth (M1)
Veekshitha11 Jul 22, 2026
9e9cfe6
refactor(admin-portal): move admin token-refresh logic into the servi…
Veekshitha11 Jul 23, 2026
b0c012a
test(admin-portal): move admin API tests to api_tests, drop ninja Tes…
Veekshitha11 Jul 23, 2026
7dd3ae8
refactor(admin-portal): align backend with repo conventions + fill te…
Veekshitha11 Jul 24, 2026
869af4a
refactor(admin-portal): reuse existing schemas + route mutations thro…
Veekshitha11 Jul 26, 2026
eca9865
refactor(admin-portal): drop the separate admin session, sign in via …
Veekshitha11 Jul 26, 2026
20720fe
refactor(admin-portal): remove org/user activate-deactivate from admi…
Veekshitha11 Aug 14, 2026
026decf
refactor(admin-portal): remove Org.is_active / OrgUser.is_active enti…
Veekshitha11 Aug 17, 2026
8034d65
fix(admin-portal): address Siddhant's review comments on #1432
Veekshitha11 Aug 17, 2026
261c10d
refactor(admin-portal): squash is_active migrations; drop SES dev fal…
Veekshitha11 Aug 19, 2026
ac1dd05
fix(admin-portal): renumber 0171_invitation_invited_in_org to 0177 (#…
Veekshitha11 Aug 19, 2026
21a3484
docs(admin-portal): explain why backfill_invited_in_org is needed (#1…
Veekshitha11 Aug 19, 2026
a9e229c
docs(admin-portal): fix stale v1/plan.md comment paths
Veekshitha11 Aug 19, 2026
00a8ee1
feat(admin-portal): add org delete to the admin portal
Veekshitha11 Aug 20, 2026
7e91eac
feat(admin-portal): add clear_org_flag and bulk_set_feature_flag
Veekshitha11 Aug 22, 2026
569399f
feat(admin-portal): add per-org and multi-org feature-flag admin routes
Veekshitha11 Aug 22, 2026
f08525c
feat(admin-portal): add broadcast notification admin routes
Veekshitha11 Aug 24, 2026
5e8b76f
refactor(admin-portal): replace bulk feature-flag route with per-org …
Veekshitha11 Aug 28, 2026
b606659
fix(admin-portal): close audit findings in the admin API
Veekshitha11 Aug 30, 2026
9a1fb41
feat(admin-portal): paginate the broadcast history route
Veekshitha11 Sep 6, 2026
b3f4991
feat(admin-portal): require an admin when creating an org
Veekshitha11 Sep 7, 2026
c3ec8e4
refactor(admin-portal): gate on the super-admin role, not UserAttributes
Veekshitha11 Sep 8, 2026
8b8a476
Merge upstream/main into feature/admin-portal-m4-users
Veekshitha11 Sep 8, 2026
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
491 changes: 491 additions & 0 deletions ddpui/api/admin_api.py

Large diffs are not rendered by default.

36 changes: 12 additions & 24 deletions ddpui/api/user_org_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ def get_current_user_v2(request, org_slug: str = None):
if org_preferences is None:
org_preferences = OrgPreferences.objects.create(org=org)

# is_platform_admin is a global (per-User) flag, so it's the same for every OrgUser row
user_attributes = UserAttributes.objects.filter(user=user).first()
is_platform_admin = bool(user_attributes and user_attributes.is_platform_admin)

# Get org default dashboard
org_default_dashboard = None
from ddpui.models.dashboard import Dashboard
Expand Down Expand Up @@ -143,6 +147,7 @@ def get_current_user_v2(request, org_slug: str = None):
subscription_plan=(curr_orguser.org.base_plan() if curr_orguser.org else None),
work_domain=curr_orguser.work_domain,
has_seen_rbac_notice=curr_orguser.has_seen_rbac_notice,
is_platform_admin=is_platform_admin,
)
)

Expand Down Expand Up @@ -321,34 +326,17 @@ def put_organization_user_v1(request, payload: OrgUserUpdatev1):
)
@has_permission(["can_edit_orguser_role"])
def post_modify_orguser_role(request, payload: OrgUserUpdateNewRole):
"""update another OrgUser's role"""
"""update another OrgUser's role (single-org wrapper around the org-param core)"""
orguser: OrgUser = request.orguser

if not orguser.new_role:
raise HttpError(403, "Insufficient permissions")

role_to_be_assgined = Role.objects.filter(uuid=payload.role_uuid).first()

if not role_to_be_assgined:
raise HttpError(400, "Invalid role")

# you cannot assign a role that is higher than yours
if role_to_be_assgined.level > orguser.new_role.level:
raise HttpError(403, "Insufficient permissions")

request_email = payload.toupdate_email.lower().strip()
orguser_to_be_assigned = (
OrgUser.objects.filter(user__email__iexact=request_email, org=orguser.org)
.exclude(user__email__iexact=orguser.user.email)
.first()
result, error = orguserfunctions.change_orguser_role_in_org(
orguser.org, orguser, payload.toupdate_email, payload.role_uuid
)
if not orguser_to_be_assigned:
raise HttpError(400, "User does not exist")

orguser_to_be_assigned.new_role = role_to_be_assgined
orguser_to_be_assigned.save()
if error:
# preserve the original status codes: permission failures are 403, the rest 400
raise HttpError(403 if error == "Insufficient permissions" else 400, error)

return {"success": 1}
return result


@user_org_router.post("/organizations/warehouse/")
Expand Down
40 changes: 39 additions & 1 deletion ddpui/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer, TokenRefreshSerializer
from django.contrib.auth.models import User

from ddpui.models.org_user import OrgUser
from ddpui.models.org_user import OrgUser, UserAttributes
from ddpui.models.role_based_access import RolePermission
from ddpui.utils import thread
from ddpui.utils.redis_client import RedisClient
Expand Down Expand Up @@ -60,6 +60,26 @@ def wrapper(*args, **kwargs):
return decorator


def platform_admin_required(view):
"""
gate a view on the global UserAttributes.is_platform_admin flag.

unlike @has_permission (which checks per-org permission slugs), this expresses
cross-org authority: a Dalgo ops user acting on orgs they don't belong to.
mirrors the @has_permission wrapper pattern above (request is args[0]).
"""

@wraps(view)
def wrapper(*args, **kwargs):
request = args[0]
ua = UserAttributes.objects.filter(user=request.orguser.user).first()
if not (ua and ua.is_platform_admin):
raise HttpError(403, "platform admin access required")
return view(*args, **kwargs)

return wrapper


def set_roles_and_permissions_in_redis(
redis_client: RedisClient, role_permissions_key: str
) -> dict:
Expand Down Expand Up @@ -146,6 +166,24 @@ def authenticate(self, request, token=None):
if orguser.org is None:
raise HttpError(400, "register an organization first")

# A deactivated org blocks all of its users at permission-load, so every
# gated endpoint 403s. Enforced here (not via empty permissions) because
# @has_permission's bare-except turns an empty-permissions 403 into a 404 —
# this raises a real 403. Reactivating the org restores access. See
# features/admin-portal/v1/plan.md §4.2.
if not orguser.org.is_active:
raise HttpError(403, "your organization has been deactivated")

# A per-org-deactivated user is blocked in THIS org only. `orguser` is
# the specific (user, org) row resolved from the x-dalgo-org header, so
# this never touches the user's membership of any OTHER org (a different
# OrgUser row). Same 403 rationale as the org check above; reactivating
# the user in this org restores access. See plan.md §4.2. The field is
# non-null (default True, backfilled from User.is_active), so an active
# user is never blocked here.
if not orguser.is_active:
raise HttpError(403, "your access to this organization has been deactivated")

redis_client = RedisClient.get_instance()
orguser_role_id = None
permissions_json = None
Expand Down
154 changes: 127 additions & 27 deletions ddpui/core/orguserfunctions.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,27 @@ def update_orguser_v1(orguser: OrgUser, payload: OrgUserUpdatev1):
return from_orguser(orguser)


def delete_orguser_v1(requestor_orguser: OrgUser, payload: DeleteOrgUserPayload):
"""delete another orguser"""
def delete_orguser_from_org(
target_org: Org,
requestor_orguser: OrgUser,
payload: DeleteOrgUserPayload,
is_platform_admin: bool = False,
):
"""
org-parameterized core of "remove a user from an org".

The target org is passed explicitly instead of being read from
requestor_orguser.org, so the admin portal can remove a user from an org the
requestor does not belong to. When is_platform_admin is True the role-level cap is
skipped — a platform admin acting cross-org has no role in target_org to compare
against. See features/admin-portal/v1/plan.md §4.4.

WARNING: deleting the OrgUser cascade-deletes the content it created
(Dashboard/Chart via created_by). Callers must surface the removal-impact count
first (see the admin removal-impact endpoint, plan.md §4.6 / research §5).
"""
orguser_to_delete = OrgUser.objects.filter(
org=requestor_orguser.org, user__email=payload.email
org=target_org, user__email=payload.email
).first()

if requestor_orguser == orguser_to_delete:
Expand All @@ -190,45 +207,71 @@ def delete_orguser_v1(requestor_orguser: OrgUser, payload: DeleteOrgUserPayload)
if orguser_to_delete is None:
return None, "user does not belong to the org"

if orguser_to_delete.new_role.level > requestor_orguser.new_role.level:
if (
not is_platform_admin
and orguser_to_delete.new_role.level > requestor_orguser.new_role.level
):
return None, "cannot delete user having higher role"

# remove the invitations associated with the org user
# remove the pending invitations for this email in the target org
Invitation.objects.filter(
invited_by__org=requestor_orguser.org, invited_email=payload.email
invited_in_org=target_org, invited_email=payload.email
).delete()

# delete the org user
# delete the org user (cascades their created content)
orguser_to_delete.delete()

return None, None


def invite_user_v1(orguser: OrgUser, payload: NewInvitationSchema):
"""invite a user to an org"""
def delete_orguser_v1(requestor_orguser: OrgUser, payload: DeleteOrgUserPayload):
"""delete another orguser in the requestor's own org (single-org wrapper)"""
return delete_orguser_from_org(requestor_orguser.org, requestor_orguser, payload)


def invite_user_to_org(
target_org: Org,
inviter_orguser: OrgUser,
payload: NewInvitationSchema,
is_platform_admin: bool = False,
):
"""
org-parameterized core of "invite a user to an org".

The target org is passed explicitly instead of being read from
inviter_orguser.org, so the admin portal can invite into an org the inviter does
not belong to (including an org with zero members). The new Invitation records
invited_in_org=target_org, which may differ from inviter_orguser.org when a
platform admin invites cross-org — so accept/cancel resolve the correct org
regardless of who sent the invite. When is_platform_admin is True the inviter-level
cap is skipped: a platform admin may invite at any role. See plan.md §4.4.
"""
frontend_url = os.getenv("FRONTEND_URL")

if orguser.org is None:
if target_org is None:
return None, "create an organization first"

invited_email = payload.invited_email.lower().strip()
if OrgUser.objects.filter(org=orguser.org, user__email__iexact=invited_email).exists():
if OrgUser.objects.filter(org=target_org, user__email__iexact=invited_email).exists():
return None, "user already has an account"

invited_role = Role.objects.filter(uuid=payload.invited_role_uuid).first()
if not invited_role:
return None, "Invalid role"

# user can only invite a role equal or lower to their role
if invited_role.level > orguser.new_role.level:
# a regular inviter can only invite at their own level or lower; a platform admin
# acting cross-org has no role in target_org, so the cap is skipped for them.
if not is_platform_admin and invited_role.level > inviter_orguser.new_role.level:
return None, "Insufficient permissions for this operation"

existing_user = User.objects.filter(email__iexact=invited_email).first()

if existing_user:
logger.info("user exists, creating new OrgUser")
OrgUser.objects.create(user=existing_user, org=orguser.org, new_role=invited_role)
awsses.send_youve_been_added_email(invited_email, orguser.user.email, orguser.org.name)
OrgUser.objects.create(user=existing_user, org=target_org, new_role=invited_role)
awsses.send_youve_been_added_email(
invited_email, inviter_orguser.user.email, target_org.name
)
return (
NewInvitationSchema(
invited_email=invited_email,
Expand All @@ -238,7 +281,7 @@ def invite_user_v1(orguser: OrgUser, payload: NewInvitationSchema):
)

invitation = Invitation.objects.filter(
invited_email__iexact=invited_email, invited_by__org=orguser.org
invited_email__iexact=invited_email, invited_in_org=target_org
).first()
if invitation:
invitation.invited_on = timezone.as_utc(datetime.utcnow())
Expand All @@ -248,18 +291,15 @@ def invite_user_v1(orguser: OrgUser, payload: NewInvitationSchema):
invitation.invited_email, invitation.invited_by.user.email, invite_url
)
logger.info(
f"Resent invitation to {invited_email} to join {orguser.org.name} "
f"Resent invitation to {invited_email} to join {target_org.name} "
f"with invite code {invitation.invite_code}",
)
return from_invitation(invitation), None

# payload.invited_by = from_orguser(orguser)
# payload.invited_on = timezone.as_utc(datetime.utcnow())
# payload.invite_code = str(uuid4())

invitation = Invitation.objects.create(
invited_email=invited_email,
invited_by=orguser,
invited_by=inviter_orguser,
invited_in_org=target_org,
invited_on=datetime.now(timezone.UTC),
invite_code=str(uuid4()),
invited_new_role=invited_role,
Expand All @@ -272,22 +312,83 @@ def invite_user_v1(orguser: OrgUser, payload: NewInvitationSchema):
)

logger.info(
f"Invited {invited_email} to join {orguser.org.name} "
f"Invited {invited_email} to join {target_org.name} "
f"with invite code {invitation.invite_code}",
)
return payload, None


def invite_user_v1(orguser: OrgUser, payload: NewInvitationSchema):
"""invite a user to the caller's own org (single-org wrapper)"""
return invite_user_to_org(orguser.org, orguser, payload)


def change_orguser_role_in_org(
target_org: Org,
requestor_orguser: OrgUser,
toupdate_email: str,
role_uuid,
is_platform_admin: bool = False,
):
"""
org-parameterized core of the role-change logic in post_modify_orguser_role.

Assigns a role to a user in target_org. The target org is passed explicitly so the
admin portal can change a role in an org the requestor does not belong to. When
is_platform_admin is True the "can't assign a role higher than your own" cap is
skipped. Returns (result, error) — the caller maps error to an HTTP status.
See plan.md §4.4.

Validation order mirrors the original endpoint for the single-org path: for a
regular requestor the missing-role check comes first, then role lookup, then the
level cap.
"""
if not is_platform_admin:
if not requestor_orguser.new_role:
return None, "Insufficient permissions"

role_to_be_assigned = Role.objects.filter(uuid=role_uuid).first()
if not role_to_be_assigned:
return None, "Invalid role"

if not is_platform_admin and role_to_be_assigned.level > requestor_orguser.new_role.level:
return None, "Insufficient permissions"

request_email = toupdate_email.lower().strip()
query = OrgUser.objects.filter(user__email__iexact=request_email, org=target_org)
# a requestor who is a member of target_org may not change their own role; a
# platform admin acting cross-org is not a member, so nothing to exclude.
if requestor_orguser is not None and requestor_orguser.org_id == target_org.id:
query = query.exclude(user__email__iexact=requestor_orguser.user.email)
orguser_to_be_assigned = query.first()

if not orguser_to_be_assigned:
return None, "User does not exist"

orguser_to_be_assigned.new_role = role_to_be_assigned
orguser_to_be_assigned.save()

return {"success": 1}, None


def accept_invitation_v1(payload: AcceptInvitationSchema):
"""accept an invitation"""
invitation = Invitation.objects.filter(invite_code=payload.invite_code).first()
if invitation is None:
return None, "invalid invite code"

# the org this invite grants membership of. Prefer the explicit invited_in_org
# (set on every new invite, and backfilled onto every pre-migration row); fall
# back to invited_by.org when it is null, so an existing pending invitation still
# resolves to exactly the same org it did before invited_in_org existed. For a
# cross-org admin invite invited_in_org is the target org, NOT invited_by.org
# (the platform admin's own org). See plan.md §4.4.
target_org = invitation.invited_in_org or invitation.invited_by.org

# we can have one auth user mapped to multiple orguser and hence multiple orgs
# but there can only be one orguser per one org
orguser = OrgUser.objects.filter(
user__email__iexact=invitation.invited_email, org=invitation.invited_by.org
user__email__iexact=invitation.invited_email, org=target_org
).first()

if not orguser:
Expand All @@ -299,8 +400,7 @@ def accept_invitation_v1(payload: AcceptInvitationSchema):
if payload.password is None:
return None, "password is required"
logger.info(
f"creating invited user {invitation.invited_email} "
f"for {invitation.invited_by.org.name}"
f"creating invited user {invitation.invited_email} for {target_org.name}"
)
user = User.objects.create_user(
username=invitation.invited_email.lower().strip(),
Expand All @@ -310,7 +410,7 @@ def accept_invitation_v1(payload: AcceptInvitationSchema):
UserAttributes.objects.create(user=user, email_verified=True)
orguser = OrgUser.objects.create(
user=user,
org=invitation.invited_by.org,
org=target_org,
new_role=invitation.invited_new_role,
work_domain=payload.work_domain,
)
Expand Down
20 changes: 20 additions & 0 deletions ddpui/migrations/0169_org_is_active.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2 on 2026-07-12 12:22

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("ddpui", "0168_alter_alert_created_by_alter_chart_created_by_and_more"),
]

operations = [
migrations.AddField(
model_name="org",
name="is_active",
field=models.BooleanField(
default=True,
help_text="False deactivates the org: its users are blocked at permission-load. Reversible.",
),
),
]
Loading
Loading