diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 4bc57c7d4..22de22583 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -117,6 +117,9 @@ { "name": "Tenant > Administration > Application Approval" }, + { + "name": "Tenant > Administration > Configuration" + }, { "name": "Tenant > Administration > Domains" }, @@ -37761,6 +37764,49 @@ "x-cipp-role": "Exchange.Mailbox.Read" } }, + "/api/ListAdminAuditLogConfig": { + "get": { + "summary": "ListAdminAuditLogConfig", + "operationId": "ListAdminAuditLogConfig", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns the tenant's Exchange Online admin audit log configuration (notably whether the\nUnified Audit Log is enabled). Read live from Exchange Online so the value is always current.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, "/api/ListAdminPortalLicenses": { "get": { "summary": "ListAdminPortalLicenses", @@ -37804,6 +37850,59 @@ "x-cipp-role": "CIPP.Core.Read" } }, + "/api/ListAdminReportSettings": { + "get": { + "summary": "ListAdminReportSettings", + "operationId": "ListAdminReportSettings", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns the tenant's admin report settings (currently whether usage-report user, group\nand site names are concealed). Read live from Graph so the value is always current.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "displayConcealedNames": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, "/api/ListAgent365PackageDetail": { "get": { "summary": "ListAgent365PackageDetail", @@ -43032,6 +43131,112 @@ "x-cipp-role": "Tenant.Standards.Read" } }, + "/api/ListCrossTenantAccess": { + "get": { + "summary": "ListCrossTenantAccess", + "operationId": "ListCrossTenantAccess", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns the default cross-tenant access policy inbound-trust settings (whether MFA,\ncompliant-device and hybrid-joined claims from other Entra tenants are trusted).\nRead live from Graph, flattened for the Configuration UI.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "appServiceConnectInbound": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "automaticUserConsentSettings": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "b2bCollaborationInbound": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "b2bCollaborationOutbound": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "b2bDirectConnectInbound": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "b2bDirectConnectOutbound": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "inboundTrust": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "invitationRedemptionIdentityProviderConfiguration": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "isCompliantDeviceAccepted": { + "x-cipp-field-source": "backend" + }, + "isHybridAzureADJoinedDeviceAccepted": { + "x-cipp-field-source": "backend" + }, + "isMfaAccepted": { + "x-cipp-field-source": "backend" + }, + "isServiceDefault": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "m365CollaborationInbound": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "m365CollaborationOutbound": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "tenantRestrictions": { + "type": "object", + "x-cipp-field-source": "graph-entity" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, "/api/ListCSPLicenses": { "get": { "summary": "ListCSPLicenses", @@ -44464,6 +44669,58 @@ "x-cipp-role": "Identity.Device.Read" } }, + "/api/ListDeviceRegistrationPolicy": { + "get": { + "summary": "ListDeviceRegistrationPolicy", + "operationId": "ListDeviceRegistrationPolicy", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns the Entra device registration policy settings surfaced here (Windows LAPS and the\nper-user device quota), flattened for the Configuration UI. Read live from Graph.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. The fields taken from Graph are the ones this endpoint selects, so they are what the response actually carries.", + "properties": { + "lapsEnabled": { + "x-cipp-field-source": "backend" + }, + "userDeviceQuota": { + "type": "integer", + "x-cipp-field-source": "graph,backend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, "/api/ListDevices": { "get": { "summary": "ListDevices", @@ -45465,6 +45722,78 @@ "x-cipp-any-tenant": true } }, + "/api/ListEntraAuthPolicy": { + "get": { + "summary": "ListEntraAuthPolicy", + "operationId": "ListEntraAuthPolicy", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns the Entra authorization policy settings (guest invite scope, SSPR, and the\ndefault-user-role permissions) flattened for the Configuration UI. Read live from Graph.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "allowedToCreateApps": { + "x-cipp-field-source": "backend" + }, + "allowedToCreateSecurityGroups": { + "x-cipp-field-source": "backend" + }, + "allowedToCreateTenants": { + "x-cipp-field-source": "backend" + }, + "allowedToReadBitLockerKeysForOwnedDevice": { + "x-cipp-field-source": "backend" + }, + "allowedToReadOtherUsers": { + "x-cipp-field-source": "backend" + }, + "allowedToUseSSPR": { + "x-cipp-field-source": "backend" + }, + "allowInvitesFrom": { + "x-cipp-field-source": "backend" + }, + "blockMsolPowerShell": { + "x-cipp-field-source": "backend" + }, + "guestUserRoleId": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, "/api/ListEquipment": { "get": { "summary": "ListEquipment", @@ -45668,6 +45997,49 @@ "x-cipp-role": "Exchange.Connector.Read" } }, + "/api/ListExchangeOrgConfig": { + "get": { + "summary": "ListExchangeOrgConfig", + "operationId": "ListExchangeOrgConfig", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns tenant-wide Exchange Online organization settings (Get-OrganizationConfig).\nRead live so values are always current before a change.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, "/api/ListExcludedLicenses": { "get": { "summary": "ListExcludedLicenses", @@ -52906,6 +53278,106 @@ "x-cipp-role": "CIPP.Core.Read" } }, + "/api/ListOrgContacts": { + "get": { + "summary": "ListOrgContacts", + "operationId": "ListOrgContacts", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns the organization notification contact addresses (marketing, technical and\nsecurity/compliance notification emails). Read live from Graph.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. The fields taken from Graph are the ones this endpoint selects, so they are what the response actually carries.", + "properties": { + "marketingNotificationEmails": { + "type": "array", + "x-cipp-field-source": "graph,backend" + }, + "securityComplianceNotificationMails": { + "type": "array", + "x-cipp-field-source": "graph,backend" + }, + "technicalNotificationMails": { + "type": "array", + "x-cipp-field-source": "graph,backend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, + "/api/ListOwaMailboxPolicy": { + "get": { + "summary": "ListOwaMailboxPolicy", + "operationId": "ListOwaMailboxPolicy", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns the default OWA mailbox policy settings (third-party storage providers, direct\nfile access). Read live from Exchange Online.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, "/api/ListPartnerRelationships": { "get": { "summary": "ListPartnerRelationships", @@ -58281,6 +58753,49 @@ "x-cipp-any-tenant": true } }, + "/api/ListSpoTenantSettings": { + "get": { + "summary": "ListSpoTenantSettings", + "operationId": "ListSpoTenantSettings", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns the SharePoint Online tenant admin (CSOM) settings - sharing link defaults,\nsync, guest access and related toggles that are not on the Graph sharepoint/settings\nobject. Read live (-SkipCache) so values are always current before a change.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Admin.Read" + } + }, "/api/ListSPOVersionCleanup": { "get": { "summary": "ListSPOVersionCleanup", @@ -58962,6 +59477,60 @@ "x-cipp-role": "Teams.Activity.Read" } }, + "/api/ListTeamsConfig": { + "get": { + "summary": "ListTeamsConfig", + "operationId": "ListTeamsConfig", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Returns a tenant-wide Teams Global policy/configuration object for the Configuration UI.\nThe policyType query parameter selects which one (meeting, messaging, client, external\naccess). Read live via the Teams admin ConfigAPI.", + "parameters": [ + { + "name": "policyType", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read" + } + }, "/api/ListTeamsLisLocation": { "get": { "summary": "ListTeamsLisLocation", @@ -59886,6 +60455,81 @@ "x-cipp-any-tenant": true } }, + "/api/ListTenantConfigFleet": { + "get": { + "summary": "ListTenantConfigFleet", + "operationId": "ListTenantConfigFleet", + "tags": [ + "Tenant > Administration > Configuration" + ], + "description": "Fleet (All Tenants) view for the Tenant Configuration page: returns one cached row per\ntenant for a given reporting-cache type, so a single request renders every tenant's\ncurrent values. Live per-tenant reads are done by each area's own List endpoint; the\nfleet view is cache-backed because live-calling every tenant is not feasible.", + "parameters": [ + { + "name": "type", + "in": "query", + "description": "The reporting-cache type to project across all tenants", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "Data": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "ETag": { + "x-cipp-field-source": "storage" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "Type": { + "x-cipp-field-source": "storage" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Config.Read", + "x-cipp-any-tenant": true + } + }, "/api/ListTenantDetails": { "get": { "summary": "ListTenantDetails", diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListAdminAuditLogConfig.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListAdminAuditLogConfig.ps1 new file mode 100644 index 000000000..afd7f8b66 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListAdminAuditLogConfig.ps1 @@ -0,0 +1,21 @@ +function Invoke-ListAdminAuditLogConfig { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns the tenant's Exchange Online admin audit log configuration (notably whether the + Unified Audit Log is enabled). Read live from Exchange Online so the value is always current. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $AuditConfig = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-AdminAuditLogConfig' + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($AuditConfig) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListAdminReportSettings.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListAdminReportSettings.ps1 new file mode 100644 index 000000000..b7614d890 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListAdminReportSettings.ps1 @@ -0,0 +1,21 @@ +function Invoke-ListAdminReportSettings { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns the tenant's admin report settings (currently whether usage-report user, group + and site names are concealed). Read live from Graph so the value is always current. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $ReportSettings = New-GraphGetRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/admin/reportSettings' -AsApp $true + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($ReportSettings) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListCrossTenantAccess.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListCrossTenantAccess.ps1 new file mode 100644 index 000000000..11dcab0bd --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListCrossTenantAccess.ps1 @@ -0,0 +1,29 @@ +function Invoke-ListCrossTenantAccess { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns the default cross-tenant access policy inbound-trust settings (whether MFA, + compliant-device and hybrid-joined claims from other Entra tenants are trusted). + Read live from Graph, flattened for the Configuration UI. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $Policy = New-GraphGetRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default' -AsApp $true + $Trust = $Policy.inboundTrust + + $Flat = [PSCustomObject]@{ + isMfaAccepted = $Trust.isMfaAccepted + isCompliantDeviceAccepted = $Trust.isCompliantDeviceAccepted + isHybridAzureADJoinedDeviceAccepted = $Trust.isHybridAzureADJoinedDeviceAccepted + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Flat) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListDeviceRegistrationPolicy.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListDeviceRegistrationPolicy.ps1 new file mode 100644 index 000000000..b5bb56071 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListDeviceRegistrationPolicy.ps1 @@ -0,0 +1,26 @@ +function Invoke-ListDeviceRegistrationPolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns the Entra device registration policy settings surfaced here (Windows LAPS and the + per-user device quota), flattened for the Configuration UI. Read live from Graph. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $Policy = New-GraphGetRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/policies/deviceRegistrationPolicy' -AsApp $true + + $Flat = [PSCustomObject]@{ + lapsEnabled = $Policy.localAdminPassword.isEnabled + userDeviceQuota = $Policy.userDeviceQuota + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Flat) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListEntraAuthPolicy.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListEntraAuthPolicy.ps1 new file mode 100644 index 000000000..9dcbd1c69 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListEntraAuthPolicy.ps1 @@ -0,0 +1,35 @@ +function Invoke-ListEntraAuthPolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns the Entra authorization policy settings (guest invite scope, SSPR, and the + default-user-role permissions) flattened for the Configuration UI. Read live from Graph. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $Policy = New-GraphGetRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/policies/authorizationPolicy/authorizationPolicy' -AsApp $true + $Defaults = $Policy.defaultUserRolePermissions + + # Flatten defaultUserRolePermissions.* to top level so the UI binds simple fields. + $Flat = [PSCustomObject]@{ + allowInvitesFrom = $Policy.allowInvitesFrom + allowedToUseSSPR = $Policy.allowedToUseSSPR + guestUserRoleId = $Policy.guestUserRoleId + blockMsolPowerShell = $Policy.blockMsolPowerShell + allowedToCreateApps = $Defaults.allowedToCreateApps + allowedToCreateSecurityGroups = $Defaults.allowedToCreateSecurityGroups + allowedToCreateTenants = $Defaults.allowedToCreateTenants + allowedToReadBitLockerKeysForOwnedDevice = $Defaults.allowedToReadBitLockerKeysForOwnedDevice + allowedToReadOtherUsers = $Defaults.allowedToReadOtherUsers + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Flat) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListExchangeOrgConfig.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListExchangeOrgConfig.ps1 new file mode 100644 index 000000000..ca957ff40 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListExchangeOrgConfig.ps1 @@ -0,0 +1,23 @@ +function Invoke-ListExchangeOrgConfig { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns tenant-wide Exchange Online organization settings (Get-OrganizationConfig). + Read live so values are always current before a change. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $Select = 'BookingsEnabled,MessageRecallEnabled,FocusedInboxOn,SendFromAliasEnabled,OnlineMeetingsByDefaultEnabled,TwoClickMailPreviewEnabled,EwsEnabled,AuditDisabled,CustomerLockboxEnabled,AppsForOfficeEnabled,OAuth2ClientProfileEnabled,ConnectorsEnabled,LinkPreviewEnabled,ReadTrackingEnabled,PublicComputersDetectionEnabled,SmtpActionableMessagesEnabled,OutlookPayEnabled' + + $Config = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-OrganizationConfig' -Select $Select + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Config) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListOrgContacts.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListOrgContacts.ps1 new file mode 100644 index 000000000..dc6a392ed --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListOrgContacts.ps1 @@ -0,0 +1,27 @@ +function Invoke-ListOrgContacts { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns the organization notification contact addresses (marketing, technical and + security/compliance notification emails). Read live from Graph. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $Org = @(New-GraphGetRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/v1.0/organization' -AsApp $true)[0] + + $Flat = [PSCustomObject]@{ + marketingNotificationEmails = @($Org.marketingNotificationEmails) + technicalNotificationMails = @($Org.technicalNotificationMails) + securityComplianceNotificationMails = @($Org.securityComplianceNotificationMails) + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Flat) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListOwaMailboxPolicy.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListOwaMailboxPolicy.ps1 new file mode 100644 index 000000000..682fc4100 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListOwaMailboxPolicy.ps1 @@ -0,0 +1,21 @@ +function Invoke-ListOwaMailboxPolicy { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns the default OWA mailbox policy settings (third-party storage providers, direct + file access). Read live from Exchange Online. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $Policy = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-OwaMailboxPolicy' -cmdParams @{ Identity = 'OwaMailboxPolicy-Default' } -Select 'AdditionalStorageProvidersAvailable,DirectFileAccessOnPublicComputersEnabled,DirectFileAccessOnPrivateComputersEnabled' + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Policy) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListSpoTenantSettings.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListSpoTenantSettings.ps1 new file mode 100644 index 000000000..cc4cf978d --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListSpoTenantSettings.ps1 @@ -0,0 +1,42 @@ +function Invoke-ListSpoTenantSettings { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Admin.Read + .DESCRIPTION + Returns the SharePoint Online tenant admin (CSOM) settings - sharing link defaults, + sync, guest access and related toggles that are not on the Graph sharepoint/settings + object. Read live (-SkipCache) so values are always current before a change. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + + # One CSOM call hydrates the whole tenant object; project the fields this section renders. + $Fields = @( + 'DefaultSharingLinkType' + 'DefaultLinkPermission' + 'DisableAddToOneDrive' + 'EnableAzureADB2BIntegration' + 'CustomScriptsRestrictMode' + 'DisableSharePointStoreAccess' + 'DisallowInfectedFileDownload' + 'ShowPeoplePickerSuggestionsForGuestUsers' + 'HideSyncButtonOnDocLib' + 'ConditionalAccessPolicy' + 'ExternalUserExpirationRequired' + 'ExternalUserExpireInDays' + 'EmailAttestationRequired' + 'EmailAttestationReAuthDays' + 'SharingCapability' + ) + + $Settings = Get-CIPPSPOTenant -TenantFilter $Tenant -SkipCache | Select-Object -Property $Fields + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Settings) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListTeamsConfig.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListTeamsConfig.ps1 new file mode 100644 index 000000000..5ba3efcab --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListTeamsConfig.ps1 @@ -0,0 +1,32 @@ +function Invoke-ListTeamsConfig { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Config.Read + .DESCRIPTION + Returns a tenant-wide Teams Global policy/configuration object for the Configuration UI. + The policyType query parameter selects which one (meeting, messaging, client, external + access). Read live via the Teams admin ConfigAPI. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Tenant = $Request.Query.tenantFilter + $PolicyType = $Request.Query.policyType + $Allowed = @('TeamsMeetingPolicy', 'TeamsMessagingPolicy', 'TeamsClientConfiguration', 'ExternalAccessPolicy') + + if ($PolicyType -notin $Allowed) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = "Unsupported policyType '$PolicyType'." } + }) + } + + $Config = New-TeamsRequestV2 -TenantFilter $Tenant -Type $PolicyType -Action Get -Identity 'Global' + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Config) + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListTenantConfigFleet.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListTenantConfigFleet.ps1 new file mode 100644 index 000000000..17a6461e1 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Configuration/Invoke-ListTenantConfigFleet.ps1 @@ -0,0 +1,62 @@ +function Invoke-ListTenantConfigFleet { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Config.Read + .DESCRIPTION + Fleet (All Tenants) view for the Tenant Configuration page: returns one cached row per + tenant for a given reporting-cache type, so a single request renders every tenant's + current values. Live per-tenant reads are done by each area's own List endpoint; the + fleet view is cache-backed because live-calling every tenant is not feasible. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + # The reporting-cache type to project across all tenants + $Type = $Request.Query.type + + # Only tenant-configuration cache types may be projected here + $AllowedTypes = @( + 'SharePointAdminSettings' + 'ExoOrganizationConfig' + 'ExoAdminAuditLogConfig' + 'AdminReportSettings' + 'AuthorizationPolicy' + 'CrossTenantAccessPolicy' + 'CsTeamsMeetingPolicy' + 'CsTeamsMessagingPolicy' + 'CsExternalAccessPolicy' + 'CsTeamsClientConfiguration' + ) + + try { + if (-not $Type) { throw 'A type is required.' } + if ($Type -notin $AllowedTypes) { throw "Unsupported configuration type '$Type'." } + + $Table = Get-CippTable -TableName 'CippReportingDB' + # RowKeys are '-'; the range bounds the scan to this type across all partitions. + $Filter = "RowKey ge '{0}-' and RowKey lt '{0}.'" -f $Type + $Rows = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + $Result = foreach ($Row in $Rows) { + if ($Row.RowKey -eq "$Type-Count") { continue } + if (-not $Row.Data) { continue } + $Object = $Row.Data | ConvertFrom-Json + # Stamp the owning tenant so the fleet table can key each row + $Object | Add-Member -NotePropertyName Tenant -NotePropertyValue $Row.PartitionKey -Force + $Object + } + + $StatusCode = [HttpStatusCode]::OK + $Body = @($Result) + } catch { + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = $_.Exception.Message } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/frontend/src/data/configStandardsMap.js b/frontend/src/data/configStandardsMap.js new file mode 100644 index 000000000..9f382ea9c --- /dev/null +++ b/frontend/src/data/configStandardsMap.js @@ -0,0 +1,73 @@ +// Maps a Configuration-page setting field to the API name of the Standard that governs it - the +// Invoke-CIPPStandard suffix, i.e. the standards.json `name` minus the "standards." prefix. +// The Configuration page is read-only; this map is how it shows whether each setting is under +// management (by a Standard or Baseline) and routes you to the system that manages it. +// +// Each entry was confirmed by finding the standard whose body reads/sets that exact property. +// Expand this as more field -> standard mappings are confirmed. A field with no entry here simply +// renders without a management chip ("not covered by a standard"), which is correct - not every +// tenant setting has a governing standard. The Teams policy standards govern several fields each +// (one policy = one standard), so those fields all point at the same standard. +export const configStandardsMap = { + // Exchange Online - Organization settings + EwsEnabled: 'DisableEWS', + AuditDisabled: 'EnableMailboxAuditing', + BookingsEnabled: 'Bookings', + MessageRecallEnabled: 'CloudMessageRecall', + FocusedInboxOn: 'FocusedInbox', + SendFromAliasEnabled: 'SendFromAlias', + CustomerLockboxEnabled: 'EnableCustomerLockbox', + + // Exchange Online - OWA mailbox policy + AdditionalStorageProvidersAvailable: 'DisableAdditionalStorageProviders', + + // SharePoint & OneDrive - Tenant settings + isLegacyAuthProtocolsEnabled: 'DisableSharePointLegacyAuth', + isResharingByExternalUsersEnabled: 'DisableReshare', + deletedUserPersonalSiteRetentionPeriodInDays: 'DeletedUserRentention', + + // SharePoint - Sharing & sync + DisallowInfectedFileDownload: 'SPDisallowInfectedFiles', + DisableAddToOneDrive: 'DisableAddShortcutsToOneDrive', + EnableAzureADB2BIntegration: 'SPAzureB2B', + ShowPeoplePickerSuggestionsForGuestUsers: 'SPGuestPeoplePicker', + + // Entra - Authorization policy + allowInvitesFrom: 'GuestInvite', + guestUserRoleId: 'DisableGuestDirectory', + allowedToUseSSPR: 'AdminSSPR', + allowedToCreateApps: 'DisableAppCreation', + allowedToCreateSecurityGroups: 'DisableSecurityGroupUsers', + allowedToCreateTenants: 'DisableTenantCreation', + allowedToReadBitLockerKeysForOwnedDevice: 'BitLockerKeysForOwnedDevice', + + // Entra - Cross-tenant access (inbound trust) + isMfaAccepted: 'ExternalMFATrusted', + isCompliantDeviceAccepted: 'ExternalComplianceTrusted', + + // Teams - Meetings (all governed by the one meeting-policy standard) + AllowAnonymousUsersToJoinMeeting: 'TeamsGlobalMeetingPolicy', + AllowAnonymousUsersToStartMeeting: 'TeamsGlobalMeetingPolicy', + AllowPSTNUsersToBypassLobby: 'TeamsGlobalMeetingPolicy', + AllowExternalParticipantGiveRequestControl: 'TeamsGlobalMeetingPolicy', + AllowParticipantGiveRequestControl: 'TeamsGlobalMeetingPolicy', + + // Teams - Messaging (all governed by the one messaging-policy standard) + AllowUserEditMessage: 'TeamsMessagingPolicy', + AllowUserDeleteMessage: 'TeamsMessagingPolicy', + AllowOwnerDeleteMessage: 'TeamsMessagingPolicy', + AllowUserDeleteChat: 'TeamsMessagingPolicy', + AllowSecurityEndUserReporting: 'TeamsMessagingPolicy', + + // Teams - External access + EnableFederationAccess: 'TeamsExternalAccessPolicy', + EnableTeamsConsumerAccess: 'TeamsExternalAccessPolicy', + EnableTeamsConsumerInbound: 'TeamsExternalAccessPolicy', + + // Teams - Client & guest access + AllowGuestUser: 'TeamsGuestAccess', + AllowEmailIntoChannel: 'TeamsEmailIntegration', + + // Audit & Reports - Unified Audit Log + UnifiedAuditLogIngestionEnabled: 'AuditLog', +} diff --git a/frontend/src/pages/tenant/manage/configuration.jsx b/frontend/src/pages/tenant/manage/configuration.jsx new file mode 100644 index 000000000..f80083a34 --- /dev/null +++ b/frontend/src/pages/tenant/manage/configuration.jsx @@ -0,0 +1,1172 @@ +import { Layout as DashboardLayout } from '../../../layouts/index' +import { HeaderedTabbedLayout } from '../../../layouts/HeaderedTabbedLayout' +import { createContext, useContext, useMemo, useState } from 'react' +import { useRouter } from 'next/router' +import { + Box, + Button, + Stack, + Alert, + Typography, + Card, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Collapse, + SvgIcon, + Autocomplete, + TextField, + Chip, + Divider, +} from '@mui/material' +import { Grid } from '@mui/system' +import { ApiGetCall } from '../../../api/ApiCall' +import { useSettings } from '../../../hooks/use-settings' +import CippButtonCard from '../../../components/CippCards/CippButtonCard' +import { CippHead } from '../../../components/CippComponents/CippHead' +import { CippDataTable } from '../../../components/CippTable/CippDataTable' +import { CippIcons } from '../../../utils/icon-registry' +import tabOptions from './tabOptions.json' +import timezoneList from '../../../data/timezoneList' +import { configStandardsMap } from '../../../data/configStandardsMap' + +// This page is deliberately read-only. Configuration is changed through Standards or Baselines +// so the change is reapplied on schedule and drift is detected - a value set by hand here would +// be neither. Every leaf is one live read of one Microsoft 365 area; nothing writes. + +const findOption = (options, value) => + options.find((o) => o.value === value) || null + +const sharingCapabilityOptions = [ + { + label: 'Only within the organization (no external sharing)', + value: 'disabled', + }, + { + label: 'New and existing guests (sign-in or verification required)', + value: 'externalUserSharingOnly', + }, + { + label: 'Anyone (anonymous access links allowed)', + value: 'externalUserAndGuestSharing', + }, + { label: 'Existing guests only', value: 'existingExternalUserSharingOnly' }, +] + +const domainModeOptions = [ + { label: 'Off', value: 'none' }, + { label: 'Restrict sharing to specific domains', value: 'allowList' }, + { label: 'Block sharing to specific domains', value: 'blockList' }, +] + +const retentionOptions = [ + 30, 90, 365, 730, 1095, 1460, 1825, 2190, 2555, 2920, 3285, 3650, +].map((days) => ({ + label: days >= 365 ? `${days / 365} year(s)` : `${days} days`, + value: String(days), +})) + +const guestInviteOptions = [ + { label: 'Anyone in the organization can invite guests', value: 'everyone' }, + { + label: 'Members and admins can invite', + value: 'adminsGuestInvitersAndAllMembers', + }, + { label: 'Admins and guest inviters only', value: 'adminsAndGuestInviters' }, + { label: 'No one can invite guests', value: 'none' }, +] + +const guestRoleOptions = [ + { + label: 'Restricted access (most limited)', + value: '2af84b1e-32c8-42b7-82bc-daa82404023b', + }, + { + label: 'Guest user (default limited access)', + value: '10dae51f-b6af-4016-8d66-8c2a99b929b3', + }, + { + label: 'Same as member users (most access)', + value: 'a0b1b346-4d3e-4e8b-98f8-753987be4970', + }, +] + +// Every section reads live (staleTime 0) so config is never stale, and only the opened section's +// area of Microsoft 365 is queried. +const liveRead = (url, tenant, queryKey) => { + const sep = url.includes('?') ? '&' : '?' + return ApiGetCall({ + url: `${url}${sep}tenantFilter=${tenant}`, + queryKey: `${queryKey}_${tenant}`, + staleTime: 0, + }) +} + +const boolText = (v) => (v ? 'Enabled' : 'Disabled') + +// Management state is supplied through context so only rows whose field maps to a Standard (see +// configStandardsMap) render a chip. `resolve` returns one of: none (no governing standard), +// pending (data not in yet), managed (a Standard/Baseline governs this tenant), available (a +// standard exists but nothing manages it here). +const ManagementContext = createContext(null) + +const MgmtChip = ({ status, label, onManage }) => { + if (!status || status.state === 'none' || status.state === 'pending') { + return null + } + const chip = + status.state === 'managed' + ? status.compliant === false + ? { color: 'warning', text: 'Drift from standard' } + : { color: 'success', text: 'Managed' } + : { color: 'default', text: `Not enforced · add to ${label}` } + return ( + + ) +} + +// One label/value line. The value is a neutral chip (booleans) or right-aligned text; a separate +// management chip under the label carries the "good vs bad" meaning where a standard governs it. +const Row = ({ label, value, chip, name }) => { + const mgmt = useContext(ManagementContext) + const status = mgmt && name ? mgmt.resolve(name) : null + return ( + + + {label} + + + {chip ? ( + + ) : ( + + {value || '—'} + + )} + + ) +} + +// Reads the live management system (whichever the Baselines flag selects) once for the tenant and +// resolves each mapped setting to managed / available. Degrades to no chip until the data is in. +const useConfigManagement = ({ tenant, baselinesActive, flagsReady }) => { + const single = !!tenant && tenant !== 'AllTenants' + const stdCompare = ApiGetCall({ + url: `/api/ListStandardsCompare?tenantFilter=${tenant}`, + queryKey: `CfgStdCompare_${tenant}`, + waiting: single && flagsReady && !baselinesActive, + staleTime: 300000, + }) + const baseAlign = ApiGetCall({ + url: `/api/ListBaselineAlignment?tenantFilter=${tenant}&byStandard=true`, + queryKey: `CfgBaseAlign_${tenant}`, + waiting: single && flagsReady && baselinesActive, + staleTime: 300000, + }) + + // apiName -> { compliant }. Standards key their per-tenant object as "standards."; + // baselines return per-standard rows keyed by a bare "" (optionally "#instance"). + const managedMap = useMemo(() => { + const m = {} + if (baselinesActive) { + const data = baseAlign.data + const rows = Array.isArray(data) + ? data + : data?.standards || data?.rows || data?.Results || [] + rows.forEach((r) => { + const nm = r?.standardName || r?.StandardName + if (!nm) return + m[String(nm).split('#')[0]] = { + compliant: r.compliant === true || r.status === 'Compliant', + } + }) + } else { + const obj = Array.isArray(stdCompare.data) + ? stdCompare.data.find((o) => o?.tenantFilter === tenant) || + stdCompare.data[0] + : null + if (obj) { + Object.entries(obj).forEach(([k, v]) => { + if (!k.startsWith('standards.')) return + const api = k.slice('standards.'.length) + if (api.includes('.')) return + m[api] = { + compliant: + v?.Value === true || + (v?.CurrentValue != null && + JSON.stringify(v.CurrentValue) === + JSON.stringify(v.ExpectedValue)), + } + }) + } + } + return m + }, [baselinesActive, stdCompare.data, baseAlign.data, tenant]) + + const ready = baselinesActive ? baseAlign.isSuccess : stdCompare.isSuccess + + return (fieldName) => { + const api = configStandardsMap[fieldName] + if (!api) return { state: 'none' } + if (!ready) return { state: 'pending' } + return managedMap[api] + ? { state: 'managed', compliant: managedMap[api].compliant } + : { state: 'available' } + } +} + +const Readout = ({ title, isFetching, error, errorText, children }) => + error ? ( + {errorText} + ) : ( + + } spacing={0}> + {children} + + + ) + +// Read-only render of a set of tenant-level boolean toggles. A field may set invert:true to show +// the opposite sense of the stored property (e.g. Exchange AuditDisabled shows as "Mailbox +// auditing enabled"). +const SwitchesReadout = ({ + tenant, + title, + readUrl, + queryKey, + fields, + description, + errorText, +}) => { + const settings = liveRead(readUrl, tenant, queryKey) + const d = Array.isArray(settings.data) ? settings.data[0] : settings.data + const known = settings.isSuccess && !!d + return ( + + {description && ( + + {description} + + )} + {fields.map((f) => { + const v = known ? (f.invert ? !d[f.name] : !!d[f.name]) : null + return ( + + ) + })} + + ) +} + +const SharePointSection = ({ tenant }) => { + const settings = liveRead( + '/api/ListSharepointSettings', + tenant, + 'SharepointSettings' + ) + const d = Array.isArray(settings.data) ? settings.data[0] : settings.data + const known = settings.isSuccess && !!d + const mode = d?.sharingDomainRestrictionMode || 'none' + const domains = ( + mode === 'allowList' + ? d?.sharingAllowedDomainList + : mode === 'blockList' + ? d?.sharingBlockedDomainList + : [] + )?.join(', ') + return ( + + + + + {mode !== 'none' && } + + + + + + + + ) +} + +const EXCHANGE_FIELDS = [ + { name: 'BookingsEnabled', label: 'Allow Microsoft Bookings' }, + { name: 'MessageRecallEnabled', label: 'Allow cloud-based message recall' }, + { name: 'FocusedInboxOn', label: 'Focused Inbox on by default' }, + { + name: 'SendFromAliasEnabled', + label: 'Allow users to send from their aliases', + }, + { + name: 'OnlineMeetingsByDefaultEnabled', + label: 'New meetings are Teams meetings by default', + }, + { + name: 'TwoClickMailPreviewEnabled', + label: 'Require two-click preview for protected mail', + }, + { name: 'EwsEnabled', label: 'Allow Exchange Web Services (EWS)' }, + { name: 'AuditDisabled', label: 'Mailbox auditing enabled', invert: true }, + { + name: 'CustomerLockboxEnabled', + label: 'Require approval for Microsoft support access (Customer Lockbox)', + }, + { + name: 'AppsForOfficeEnabled', + label: 'Allow Outlook add-ins (apps for Office)', + }, + { + name: 'ConnectorsEnabled', + label: 'Allow connected apps (connectors) in Outlook/Groups', + }, + { + name: 'LinkPreviewEnabled', + label: 'Show link previews in Outlook on the web', + }, + { + name: 'ReadTrackingEnabled', + label: 'Allow read receipts / message tracking', + }, + { + name: 'PublicComputersDetectionEnabled', + label: 'Detect public computers in OWA', + }, + { + name: 'SmtpActionableMessagesEnabled', + label: 'Allow actionable messages in email', + }, + { name: 'OutlookPayEnabled', label: 'Allow Microsoft Pay in Outlook' }, +] + +const ExchangeOrgSection = ({ tenant }) => ( + +) + +const SPO_CSOM_FIELDS = [ + { + name: 'DisableAddToOneDrive', + label: 'Disable "Add shortcut to OneDrive" button', + }, + { + name: 'EnableAzureADB2BIntegration', + label: 'Enable SharePoint/OneDrive B2B integration', + }, + { name: 'CustomScriptsRestrictMode', label: 'Block custom scripts' }, + { + name: 'DisableSharePointStoreAccess', + label: 'Disable SharePoint Store app access', + }, + { + name: 'DisallowInfectedFileDownload', + label: 'Block downloading malware-infected files', + }, + { + name: 'ShowPeoplePickerSuggestionsForGuestUsers', + label: 'Show guests in the People Picker', + }, + { name: 'HideSyncButtonOnDocLib', label: 'Hide the SharePoint Sync button' }, +] + +const SpoSharingSection = ({ tenant }) => ( + +) + +const ENTRA_SWITCHES = [ + { + name: 'allowedToUseSSPR', + label: 'Admins can use Self-Service Password Reset', + }, + { name: 'allowedToCreateApps', label: 'Users can register applications' }, + { + name: 'allowedToCreateSecurityGroups', + label: 'Users can create security groups', + }, + { name: 'allowedToCreateTenants', label: 'Users can create tenants' }, + { + name: 'allowedToReadBitLockerKeysForOwnedDevice', + label: 'Users can read their own BitLocker keys', + }, + { + name: 'allowedToReadOtherUsers', + label: 'Users can read other users in the directory', + }, +] + +const EntraAuthSection = ({ tenant }) => { + const settings = liveRead( + '/api/ListEntraAuthPolicy', + tenant, + 'EntraAuthPolicy' + ) + const d = Array.isArray(settings.data) ? settings.data[0] : settings.data + const known = settings.isSuccess && !!d + return ( + + + + {ENTRA_SWITCHES.map((f) => ( + + ))} + + ) +} + +const TEAMS_MEETING_FIELDS = [ + { + name: 'AllowAnonymousUsersToJoinMeeting', + label: 'Allow anonymous users to join meetings', + }, + { + name: 'AllowAnonymousUsersToStartMeeting', + label: 'Allow anonymous users to start meetings', + }, + { + name: 'AllowPSTNUsersToBypassLobby', + label: 'Dial-in users bypass the lobby', + }, + { + name: 'AllowExternalParticipantGiveRequestControl', + label: 'External participants can give/request control', + }, + { + name: 'AllowParticipantGiveRequestControl', + label: 'Participants can give/request control', + }, +] + +const TEAMS_MESSAGING_FIELDS = [ + { name: 'AllowUserEditMessage', label: 'Users can edit their messages' }, + { name: 'AllowUserDeleteMessage', label: 'Users can delete their messages' }, + { name: 'AllowOwnerDeleteMessage', label: 'Owners can delete any message' }, + { name: 'AllowUserDeleteChat', label: 'Users can delete chats' }, + { + name: 'AllowSecurityEndUserReporting', + label: 'Users can report messages as a security concern', + }, +] + +const TEAMS_CLIENT_FIELDS = [ + { name: 'AllowGuestUser', label: 'Allow guest users in Teams' }, + { + name: 'AllowEmailIntoChannel', + label: 'Allow email into a channel address', + }, +] + +const TEAMS_EXTERNAL_FIELDS = [ + { + name: 'EnableFederationAccess', + label: 'Allow federation with other organizations', + }, + { + name: 'EnableTeamsConsumerAccess', + label: 'Allow communication with unmanaged (consumer) Teams', + }, + { + name: 'EnableTeamsConsumerInbound', + label: 'Unmanaged Teams users can initiate contact', + }, +] + +// Factory for a Teams Global-policy leaf (each is one policy type = one read). +const teamsLeaf = (title, policyType, fields) => + function TeamsSection({ tenant }) { + return ( + + ) + } + +const TeamsMeetingSection = teamsLeaf( + 'Meetings', + 'TeamsMeetingPolicy', + TEAMS_MEETING_FIELDS +) +const TeamsMessagingSection = teamsLeaf( + 'Messaging', + 'TeamsMessagingPolicy', + TEAMS_MESSAGING_FIELDS +) +const TeamsExternalSection = teamsLeaf( + 'External access', + 'ExternalAccessPolicy', + TEAMS_EXTERNAL_FIELDS +) +const TeamsClientSection = teamsLeaf( + 'Client & guest access', + 'TeamsClientConfiguration', + TEAMS_CLIENT_FIELDS +) + +const XTAP_FIELDS = [ + { name: 'isMfaAccepted', label: 'Trust MFA claims from other Entra tenants' }, + { + name: 'isCompliantDeviceAccepted', + label: 'Trust compliant-device claims from other tenants', + }, + { + name: 'isHybridAzureADJoinedDeviceAccepted', + label: 'Trust hybrid-joined-device claims from other tenants', + }, +] + +const CrossTenantAccessSection = ({ tenant }) => ( + +) + +const OWA_FIELDS = [ + { + name: 'AdditionalStorageProvidersAvailable', + label: 'Allow third-party storage providers in OWA', + }, + { + name: 'DirectFileAccessOnPublicComputersEnabled', + label: 'Direct file access on public computers', + }, + { + name: 'DirectFileAccessOnPrivateComputersEnabled', + label: 'Direct file access on private computers', + }, +] + +const OwaMailboxSection = ({ tenant }) => ( + +) + +const ORG_CONTACT_FIELDS = [ + { + name: 'technicalNotificationMails', + label: 'Technical notification emails', + }, + { + name: 'securityComplianceNotificationMails', + label: 'Security & compliance notification emails', + }, + { + name: 'marketingNotificationEmails', + label: 'Marketing notification emails', + }, +] + +const OrgContactsSection = ({ tenant }) => { + const settings = liveRead('/api/ListOrgContacts', tenant, 'OrgContacts') + const d = Array.isArray(settings.data) ? settings.data[0] : settings.data + return ( + + {ORG_CONTACT_FIELDS.map((f) => ( + + ))} + + ) +} + +const DeviceRegSection = ({ tenant }) => { + const settings = liveRead( + '/api/ListDeviceRegistrationPolicy', + tenant, + 'DeviceRegPolicy' + ) + const d = Array.isArray(settings.data) ? settings.data[0] : settings.data + const known = settings.isSuccess && !!d + return ( + + + + + ) +} + +const AuditLogSection = ({ tenant }) => ( + +) + +const UsageReportsSection = ({ tenant }) => ( + +) + +// Each leaf = one Microsoft 365 resource = one live read (single-tenant) and one cached fleet +// type (all-tenants). Leaves are grouped into categories for the nav tree. +const SECTIONS = [ + { + key: 'sharepoint', + category: 'SharePoint & OneDrive', + title: 'Tenant settings', + icon: , + Component: SharePointSection, + cacheType: 'SharePointAdminSettings', + columns: [ + 'tenantDefaultTimezone', + 'sharingCapability', + 'sharingDomainRestrictionMode', + ], + }, + { + key: 'spo-sharing', + category: 'SharePoint & OneDrive', + title: 'Sharing & sync', + icon: , + Component: SpoSharingSection, + cacheType: null, + columns: [], + }, + { + key: 'exchange', + category: 'Exchange Online', + title: 'Organization settings', + icon: , + Component: ExchangeOrgSection, + cacheType: 'ExoOrganizationConfig', + columns: [ + 'BookingsEnabled', + 'FocusedInboxOn', + 'EwsEnabled', + 'AuditDisabled', + ], + }, + { + key: 'exchange-owa', + category: 'Exchange Online', + title: 'OWA mailbox policy', + icon: , + Component: OwaMailboxSection, + cacheType: null, + columns: [], + }, + { + key: 'entra-auth', + category: 'Entra (Identity)', + title: 'Authorization policy', + icon: , + Component: EntraAuthSection, + cacheType: 'AuthorizationPolicy', + columns: ['allowInvitesFrom', 'guestUserRoleId'], + }, + { + key: 'entra-xtap', + category: 'Entra (Identity)', + title: 'Cross-tenant access', + icon: , + Component: CrossTenantAccessSection, + cacheType: 'CrossTenantAccessPolicy', + columns: ['isMfaAccepted', 'isCompliantDeviceAccepted'], + }, + { + key: 'entra-devicereg', + category: 'Entra (Identity)', + title: 'Device registration', + icon: , + Component: DeviceRegSection, + cacheType: null, + columns: [], + }, + { + key: 'teams-meetings', + category: 'Teams', + title: 'Meetings', + icon: , + Component: TeamsMeetingSection, + cacheType: 'CsTeamsMeetingPolicy', + columns: [ + 'AllowAnonymousUsersToJoinMeeting', + 'AllowExternalParticipantGiveRequestControl', + ], + }, + { + key: 'teams-messaging', + category: 'Teams', + title: 'Messaging', + icon: , + Component: TeamsMessagingSection, + cacheType: 'CsTeamsMessagingPolicy', + columns: ['AllowUserDeleteMessage', 'AllowSecurityEndUserReporting'], + }, + { + key: 'teams-external', + category: 'Teams', + title: 'External access', + icon: , + Component: TeamsExternalSection, + cacheType: 'CsExternalAccessPolicy', + columns: ['EnableFederationAccess', 'EnableTeamsConsumerAccess'], + }, + { + key: 'teams-client', + category: 'Teams', + title: 'Client & guest access', + icon: , + Component: TeamsClientSection, + cacheType: 'CsTeamsClientConfiguration', + columns: ['AllowGuestUser', 'AllowEmailIntoChannel'], + }, + { + key: 'org-contacts', + category: 'Organization', + title: 'Notification contacts', + icon: , + Component: OrgContactsSection, + cacheType: null, + columns: [], + }, + { + key: 'audit', + category: 'Audit & Reports', + title: 'Unified Audit Log', + icon: , + Component: AuditLogSection, + cacheType: 'ExoAdminAuditLogConfig', + columns: ['UnifiedAuditLogIngestionEnabled'], + }, + { + key: 'reports', + category: 'Audit & Reports', + title: 'Usage report privacy', + icon: , + Component: UsageReportsSection, + cacheType: 'AdminReportSettings', + columns: ['displayConcealedNames'], + }, +] + +const CATEGORIES = SECTIONS.reduce( + (acc, s) => (acc.includes(s.category) ? acc : [...acc, s.category]), + [] +) + +// Flat search index: every leaf plus its individual settings, each pointing at the leaf to open. +const fieldEntries = (leafKey, category, fields) => + fields.map((f) => ({ label: `${category} · ${f.label}`, key: leafKey })) + +const SEARCH_INDEX = [ + ...SECTIONS.map((s) => ({ label: `${s.category} · ${s.title}`, key: s.key })), + ...fieldEntries('exchange', 'Exchange', EXCHANGE_FIELDS), + ...fieldEntries('spo-sharing', 'SharePoint', SPO_CSOM_FIELDS), + ...fieldEntries('entra-auth', 'Entra', ENTRA_SWITCHES), + ...fieldEntries('entra-xtap', 'Cross-tenant access', XTAP_FIELDS), + ...fieldEntries('exchange-owa', 'OWA', OWA_FIELDS), + ...fieldEntries('org-contacts', 'Organization', ORG_CONTACT_FIELDS), + { label: 'Entra · Windows LAPS', key: 'entra-devicereg' }, + { label: 'Entra · Max devices per user', key: 'entra-devicereg' }, + ...fieldEntries('teams-meetings', 'Teams Meetings', TEAMS_MEETING_FIELDS), + ...fieldEntries('teams-messaging', 'Teams Messaging', TEAMS_MESSAGING_FIELDS), + ...fieldEntries('teams-external', 'Teams External', TEAMS_EXTERNAL_FIELDS), + ...fieldEntries('teams-client', 'Teams Client', TEAMS_CLIENT_FIELDS), + // Bespoke leaves (their fields are not in a shared array) - a few high-value search terms. + { label: 'SharePoint · Default timezone', key: 'sharepoint' }, + { label: 'SharePoint · External sharing level', key: 'sharepoint' }, + { label: 'SharePoint · Domain sharing restriction', key: 'sharepoint' }, + { label: 'SharePoint · Allow legacy authentication', key: 'sharepoint' }, + { label: 'Entra · Guest invite scope', key: 'entra-auth' }, + { label: 'Entra · Guest access level', key: 'entra-auth' }, +] + +// All-tenants (fleet) view: cached values across every tenant for the leaf's type. +const FleetTable = ({ section }) => { + const fleet = ApiGetCall({ + url: `/api/ListTenantConfigFleet?type=${section.cacheType}`, + queryKey: `Fleet_${section.cacheType}`, + staleTime: 0, + }) + return ( + + + Cached values across all tenants. Select a specific tenant to view it + live. + + + + ) +} + +const Page = () => { + const router = useRouter() + const settings = useSettings() + const currentTenant = router.query.tenantFilter || settings.currentTenant + const title = 'Manage Tenant' + + // Baselines and classic Standards are mutually exclusive - the Baselines feature flag switches + // the estate from one to the other. Point the "manage this properly" call-to-action at whichever + // one is live. + const featureFlags = ApiGetCall({ + url: '/api/ListFeatureFlags', + queryKey: 'featureFlags', + staleTime: 600000, + }) + const baselinesActive = + Array.isArray(featureFlags.data) && + featureFlags.data.some( + (f) => + (f.Id === 'Baselines' || f.Name === 'Baselines') && + (f.Enabled === true || f.enabled === true) + ) + const mgmtLabel = baselinesActive ? 'Baselines' : 'Standards' + const mgmtNoun = baselinesActive ? 'Baseline' : 'Standard' + const mgmtPath = baselinesActive + ? '/tenant/baselines/templates' + : '/tenant/standards/templates' + + const resolveManagement = useConfigManagement({ + tenant: currentTenant, + baselinesActive, + flagsReady: featureFlags.isSuccess, + }) + const mgmt = { + resolve: resolveManagement, + label: mgmtLabel, + onManage: () => router.push(mgmtPath), + } + + // The selected leaf lives in the URL (?section=) so sections are deep-linkable. + const sectionKey = + SECTIONS.find((s) => s.key === router.query.section)?.key || SECTIONS[0].key + const activeSection = + SECTIONS.find((s) => s.key === sectionKey) || SECTIONS[0] + const ActiveComponent = activeSection.Component + const isAllTenants = currentTenant === 'AllTenants' + const [openCategory, setOpenCategory] = useState(activeSection.category) + + const goToSection = (key) => { + const sec = SECTIONS.find((s) => s.key === key) + if (sec) setOpenCategory(sec.category) + router.replace( + { pathname: router.pathname, query: { ...router.query, section: key } }, + undefined, + { shallow: true } + ) + } + + return ( + + + + {!currentTenant ? ( + + + Select a tenant to view its configuration, or choose All Tenants + for a fleet overview. + + + ) : ( + <> + + + + } + action={ + + } + sx={{ mb: 2 }} + > + This is a read-only view of tenant configuration. To change any of + these settings, enforce it through a {mgmtNoun} so it stays + applied and configuration drift is detected. + + + {/* Left: search + category tree. Right: single-tenant live config, or all-tenants fleet table. */} + + o.label} + onChange={(e, value) => value && goToSection(value.key)} + renderInput={(params) => ( + + )} + isOptionEqualToValue={(o, v) => o.label === v.label} + clearOnBlur + blurOnSelect + /> + + + {CATEGORIES.map((category) => { + const leaves = SECTIONS.filter( + (s) => s.category === category + ) + const open = openCategory === category + return ( + + + setOpenCategory(open ? null : category) + } + > + + + + + + + + {leaves.map((section) => ( + goToSection(section.key)} + > + + + {section.icon} + + + + + ))} + + + + ) + })} + + + + + {isAllTenants ? ( + activeSection.cacheType ? ( + + ) : ( + + + A fleet overview is not available for this area yet. + Select a specific tenant to view its settings. + + + ) + ) : ( + + + + )} + + + + )} + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/frontend/src/pages/tenant/manage/tabOptions.json b/frontend/src/pages/tenant/manage/tabOptions.json index 9ca3bafc1..bd1c2266e 100644 --- a/frontend/src/pages/tenant/manage/tabOptions.json +++ b/frontend/src/pages/tenant/manage/tabOptions.json @@ -4,6 +4,11 @@ "path": "/tenant/manage/edit", "icon": "Settings" }, + { + "label": "Configuration", + "path": "/tenant/manage/configuration", + "icon": "Tune" + }, { "label": "Manage Drift", "path": "/tenant/manage/drift",