mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
3a9a074be1
Under D-267 a managed tenant saw every operator-provisioned access as
"Platform support". A customer in two operator groups run by different teams
got two identical entries under "Who can read this tenant" and could not tell
them apart.
A Global Admin can now give an operator group a customer label (at most 80
characters) with the existing PATCH global/tenant-groups/{groupId}, which takes
only the fields to change: name and/or customerLabel, where an empty label
clears it. The customer sees the label as the entry name; a group without one
and direct grants keep "Platform support". Owned (self-service) groups refuse a
label with 409, because their customers see the managing tenant's name.
RenameTenantGroupAsync becomes UpdateTenantGroupAsync, one read-modify-write on
the meta row.
DelegationSelfService.CustomerNameOf is now the one source of the name a
customer sees for a group. The managers view and the Group detail of the audit
rows written under every member tenant (assign, unassign, group delete) both use
it. Those audit rows carried the internal group name to the customer's audit
page before this change.
Admin, Tenant Groups: operator groups show "Customers see: ..." and get a
"Customer label" button next to Rename. The customer page is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
226 lines
14 KiB
C#
226 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace AutopilotMonitor.Shared.DataAccess
|
|
{
|
|
/// <summary>
|
|
/// Repository for admin and tenant member management.
|
|
/// Covers: GlobalAdmins, TenantAdmins, McpUsers, DelegatedAdmins tables.
|
|
/// </summary>
|
|
public interface IAdminRepository
|
|
{
|
|
// --- Global Admins ---
|
|
Task<bool> IsGlobalAdminAsync(string upn);
|
|
/// <summary>
|
|
/// Resolves the platform role for a UPN: "GlobalAdmin", "GlobalReader", or null when there is no
|
|
/// enabled GlobalAdmins row. Empty/missing Role on an existing enabled row ⇒ "GlobalAdmin" (back-compat).
|
|
/// </summary>
|
|
Task<string?> GetGlobalRoleAsync(string upn);
|
|
Task<List<GlobalAdminEntry>> GetAllGlobalAdminsAsync();
|
|
Task<bool> AddGlobalAdminAsync(string upn, string addedBy);
|
|
Task<bool> RemoveGlobalAdminAsync(string upn);
|
|
Task<bool> DisableGlobalAdminAsync(string upn);
|
|
|
|
// --- MCP Users ---
|
|
Task<bool> IsMcpUserAsync(string upn);
|
|
Task<McpUserEntry?> GetMcpUserAsync(string upn);
|
|
Task<List<McpUserEntry>> GetAllMcpUsersAsync();
|
|
Task<bool> AddMcpUserAsync(string upn, string addedBy);
|
|
Task<bool> RemoveMcpUserAsync(string upn);
|
|
Task<bool> SetMcpUserEnabledAsync(string upn, bool isEnabled);
|
|
Task<bool> SetMcpUserUsagePlanAsync(string upn, string? usagePlan);
|
|
|
|
// --- Delegated Admins (scoped-global / "MSP mode") ---
|
|
/// <summary>Every assignment row across all delegated admins (full-table scan — the management UI lists
|
|
/// all grants; the table holds only admin assignments, so this stays small). Not a hot path.</summary>
|
|
Task<List<DelegatedAdminEntry>> GetAllDelegatedAdminsAsync();
|
|
/// <summary>All assignment rows for one delegated-admin UPN (PK scan), regardless of status/enabled.
|
|
/// Callers that need only effective scope filter on Status==Active && IsEnabled.</summary>
|
|
Task<List<DelegatedAdminEntry>> GetDelegatedTenantsAsync(string upn);
|
|
/// <summary>All assignment rows targeting one tenant (cross-partition RowKey scan — admin UI, not hot path).
|
|
/// Powers the customer's "who manages my tenant?" view including pending requests.</summary>
|
|
Task<List<DelegatedAdminEntry>> GetDelegatedAssigneesAsync(string tenantId);
|
|
Task<DelegatedAdminEntry?> GetDelegatedAdminAsync(string upn, string tenantId);
|
|
/// <summary>Creates or replaces an assignment row (upsert on PK=upn, RK=tenantId).</summary>
|
|
Task<bool> UpsertDelegatedAdminAsync(string upn, string tenantId, string role, string status, string source, string grantedBy);
|
|
Task<bool> SetDelegatedAdminStatusAsync(string upn, string tenantId, string status);
|
|
Task<bool> SetDelegatedAdminEnabledAsync(string upn, string tenantId, bool isEnabled);
|
|
Task<bool> RemoveDelegatedAdminAsync(string upn, string tenantId);
|
|
|
|
// --- Tenant Groups (app-internal tenant bundles for delegated admins / "MSP mode") ---
|
|
/// <summary>Creates a group (meta row) and returns the generated groupId.</summary>
|
|
Task<string> CreateTenantGroupAsync(string name, string createdBy);
|
|
/// <summary>
|
|
/// Updates a group's meta row: <paramref name="name"/> and <paramref name="customerLabel"/> are each left
|
|
/// unchanged when null; an empty label clears it. Returns false if the group does not exist.
|
|
/// </summary>
|
|
Task<bool> UpdateTenantGroupAsync(string groupId, string? name, string? customerLabel);
|
|
/// <summary>Deletes a group: all rows in its partition (meta + membership) AND every UPN
|
|
/// assignment referencing it (cross-partition RowKey scan of the assignments table).</summary>
|
|
Task<bool> DeleteTenantGroupAsync(string groupId);
|
|
/// <summary>All groups with their tenant members + assignee counts — for the management UI (not hot path).</summary>
|
|
Task<List<TenantGroup>> GetAllTenantGroupsAsync();
|
|
Task<TenantGroup?> GetTenantGroupAsync(string groupId);
|
|
/// <summary>Creates the meta row of a tenant-OWNED (self-service) group if missing — idempotent, never touches an existing group.</summary>
|
|
Task EnsureOwnedTenantGroupAsync(string groupId, string name, string ownerTenantId);
|
|
/// <summary>Ids of every group partition holding a membership row for the tenant (cross-partition RowKey scan; existence is re-checked meta-backed by callers).</summary>
|
|
Task<List<string>> GetGroupIdsContainingTenantAsync(string tenantId);
|
|
Task<bool> AddTenantToGroupAsync(string groupId, string tenantId);
|
|
Task<bool> RemoveTenantFromGroupAsync(string groupId, string tenantId);
|
|
/// <summary>Tenant IDs (lowercase) in a group, excluding the meta row (HOT PATH — PartitionKey scan).</summary>
|
|
Task<List<string>> GetGroupTenantsAsync(string groupId);
|
|
/// <summary>The group's tenant members PLUS its meta-row flags from the same single partition read
|
|
/// (HOT PATH — scope resolution). Null when the group does not exist (no meta row).</summary>
|
|
Task<TenantGroupMembership?> GetGroupMembershipAsync(string groupId);
|
|
/// <summary>Creates or replaces a UPN→group assignment.</summary>
|
|
Task<bool> AssignGroupAsync(string upn, string groupId, string role, bool isEnabled, string assignedBy);
|
|
Task<bool> UnassignGroupAsync(string upn, string groupId);
|
|
/// <summary>All group assignments for one UPN (HOT PATH — PartitionKey point-scan; scope resolution).</summary>
|
|
Task<List<TenantGroupAssignment>> GetGroupAssignmentsForUpnAsync(string upn);
|
|
/// <summary>All UPNs assigned to one group (cross-partition RowKey scan — management UI, not hot path).</summary>
|
|
Task<List<TenantGroupAssignment>> GetGroupAssigneesAsync(string groupId);
|
|
|
|
// --- Admin identity bindings (tid + oid behind every cross-tenant-role UPN) ---
|
|
/// <summary>The binding for a UPN, or null when none exists (⇒ no cross-tenant role resolves).</summary>
|
|
Task<AdminIdentityBinding?> GetIdentityBindingAsync(string upn);
|
|
/// <summary>Every binding — for the operator management UI (small table, not a hot path).</summary>
|
|
Task<List<AdminIdentityBinding>> GetAllIdentityBindingsAsync();
|
|
/// <summary>Every binding homed in one tenant (single-partition property filter) — the delegated slot count's attribution.</summary>
|
|
Task<List<AdminIdentityBinding>> GetIdentityBindingsByHomeTenantAsync(string homeTenantId);
|
|
/// <summary>Creates or replaces the binding for a UPN. <paramref name="objectId"/> null/empty ⇒ pinned on first sign-in.</summary>
|
|
Task<bool> UpsertIdentityBindingAsync(string upn, string tenantId, string? objectId, string boundBy);
|
|
/// <summary>
|
|
/// Pins <paramref name="objectId"/> onto an existing binding ONLY IF the binding is homed in
|
|
/// <paramref name="tenantId"/> and has no object id yet (optimistic-concurrency guarded). Returns the
|
|
/// binding as stored after the attempt (null when no binding exists) so the caller can re-verify.
|
|
/// </summary>
|
|
Task<AdminIdentityBinding?> TryPinIdentityObjectIdAsync(string upn, string tenantId, string objectId);
|
|
Task<bool> RemoveIdentityBindingAsync(string upn);
|
|
|
|
// --- Tenant Members ---
|
|
Task<List<TenantMember>> GetTenantMembersAsync(string tenantId);
|
|
Task<bool> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false);
|
|
Task<bool> RemoveTenantMemberAsync(string tenantId, string upn);
|
|
Task<bool> UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens);
|
|
Task<bool> SetTenantMemberEnabledAsync(string tenantId, string upn, bool isEnabled);
|
|
Task<TenantMember?> GetTenantMemberAsync(string tenantId, string upn);
|
|
Task<bool> IsTenantAdminAsync(string tenantId, string upn);
|
|
}
|
|
|
|
public class GlobalAdminEntry
|
|
{
|
|
public string Upn { get; set; } = string.Empty;
|
|
public bool IsEnabled { get; set; } = true;
|
|
public DateTime AddedAt { get; set; }
|
|
public string AddedBy { get; set; } = string.Empty;
|
|
/// <summary>Platform role: "GlobalAdmin" (default) or "GlobalReader". Empty ⇒ GlobalAdmin (back-compat).</summary>
|
|
public string Role { get; set; } = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// One delegated-admin assignment: UPN X may access tenant Y at role Role. The "scoped global" tier
|
|
/// (subset of tenants) between a single-tenant member and a platform GlobalAdmin. Surfaced as "MSP mode".
|
|
/// </summary>
|
|
public class DelegatedAdminEntry
|
|
{
|
|
public string Upn { get; set; } = string.Empty;
|
|
public string TenantId { get; set; } = string.Empty;
|
|
/// <summary>Constants.DelegatedRoles: "DelegatedReader" (default) or "DelegatedAdmin".</summary>
|
|
public string Role { get; set; } = string.Empty;
|
|
public bool IsEnabled { get; set; } = true;
|
|
/// <summary>Constants.DelegatedStatus: "Active" / "PendingApproval" / "Revoked". Only Active confers scope.</summary>
|
|
public string Status { get; set; } = string.Empty;
|
|
/// <summary>Constants.DelegatedSource: "OperatorGranted" / "CustomerDelegated".</summary>
|
|
public string Source { get; set; } = string.Empty;
|
|
public DateTime GrantedAt { get; set; }
|
|
public string GrantedBy { get; set; } = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Tenant Group: an app-internal named bundle of tenants. A delegated admin assigned to the
|
|
/// group (see <see cref="TenantGroupAssignment"/>) gains read scope to every tenant in
|
|
/// <see cref="TenantIds"/>. Adding a tenant to the group grants it to all assignees at once.
|
|
/// </summary>
|
|
public class TenantGroup
|
|
{
|
|
public string GroupId { get; set; } = string.Empty;
|
|
public string Name { get; set; } = string.Empty;
|
|
public string CreatedBy { get; set; } = string.Empty;
|
|
public DateTime CreatedAt { get; set; }
|
|
/// <summary>Tenant IDs (lowercase) in this group.</summary>
|
|
public List<string> TenantIds { get; set; } = new();
|
|
/// <summary>Number of UPNs assigned to this group (== <see cref="Assignees"/>.Count).</summary>
|
|
public int AssigneeCount { get; set; }
|
|
/// <summary>The UPNs assigned to this group (for the management UI).</summary>
|
|
public List<TenantGroupAssignment> Assignees { get; set; } = new();
|
|
/// <summary>The managing tenant that owns this self-service group (<c>msp-{tenantId}</c>); null for operator-created groups.</summary>
|
|
public string? OwnerTenantId { get; set; }
|
|
/// <summary>Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer.</summary>
|
|
public string? CustomerLabel { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hot-path projection of one Tenant Group: its tenant members and the meta-row flags that shape the
|
|
/// scope they confer, read from the single group partition (see <see cref="IAdminRepository.GetGroupMembershipAsync"/>).
|
|
/// </summary>
|
|
public class TenantGroupMembership
|
|
{
|
|
public string GroupId { get; set; } = string.Empty;
|
|
/// <summary>Tenant IDs (lowercase) in this group.</summary>
|
|
public List<string> TenantIds { get; set; } = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The immutable Entra identity behind a cross-tenant-role UPN: the HOME tenant the UPN was granted for
|
|
/// and, once known, the user's object id. A role row (GlobalAdmins / DelegatedAdmins / TenantGroupAssignments)
|
|
/// confers nothing unless the caller's validated JWT matches this binding.
|
|
/// </summary>
|
|
public class AdminIdentityBinding
|
|
{
|
|
public string Upn { get; set; } = string.Empty;
|
|
/// <summary>The admin's home Entra tenant id (lowercase GUID) — must equal the JWT tid.</summary>
|
|
public string TenantId { get; set; } = string.Empty;
|
|
/// <summary>The admin's Entra object id (lowercase GUID) — must equal the JWT oid. Empty ⇒ pinned on first sign-in.</summary>
|
|
public string ObjectId { get; set; } = string.Empty;
|
|
public string BoundBy { get; set; } = string.Empty;
|
|
public DateTime BoundAt { get; set; }
|
|
/// <summary>When the object id was pinned (grant time, or the first matching sign-in). Null while unpinned.</summary>
|
|
public DateTime? ObjectIdPinnedAt { get; set; }
|
|
public bool IsObjectIdPinned => !string.IsNullOrEmpty(ObjectId);
|
|
}
|
|
|
|
/// <summary>One UPN→group assignment. PK=UPN, RK=groupId in storage.</summary>
|
|
public class TenantGroupAssignment
|
|
{
|
|
public string Upn { get; set; } = string.Empty;
|
|
public string GroupId { get; set; } = string.Empty;
|
|
/// <summary>Constants.DelegatedRoles: "DelegatedReader" (default) or "DelegatedAdmin".</summary>
|
|
public string Role { get; set; } = string.Empty;
|
|
public bool IsEnabled { get; set; } = true;
|
|
public string AssignedBy { get; set; } = string.Empty;
|
|
public DateTime AssignedAt { get; set; }
|
|
}
|
|
|
|
public class McpUserEntry
|
|
{
|
|
public string Upn { get; set; } = string.Empty;
|
|
public bool IsEnabled { get; set; } = true;
|
|
public DateTime AddedAt { get; set; }
|
|
public string AddedBy { get; set; } = string.Empty;
|
|
public string? UsagePlan { get; set; }
|
|
}
|
|
|
|
public class TenantMember
|
|
{
|
|
public string Upn { get; set; } = string.Empty;
|
|
public string TenantId { get; set; } = string.Empty;
|
|
public string Role { get; set; } = "Admin";
|
|
public bool IsEnabled { get; set; } = true;
|
|
public bool CanManageBootstrapTokens { get; set; }
|
|
public DateTime AddedAt { get; set; }
|
|
public string AddedBy { get; set; } = string.Empty;
|
|
}
|
|
|
|
}
|