4 Commits

7 changed files with 615 additions and 8 deletions

View File

@@ -84,6 +84,14 @@ instead of importing access ORM models or backend dependency internals.
The detailed module boundary and serialization fields are documented in
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md).
For scheduled and event-driven work, Access provides
`auth.automationPrincipalProvider`. Automation records store only an owner
account/membership reference and an explicit least-privilege scope grant, not
a session or API token. At delivery time Access rebuilds the principal from
current roles, groups, functions, and delegations and intersects that
authorization with the stored grant. Missing, inactive, moved, or
under-authorized owners fail closed before module work starts.
## WebUI Package
The repository root and `webui/` directory both expose the package

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session
@@ -15,6 +15,10 @@ from govoplan_core.core.access import (
PrincipalRef,
PrincipalResolver,
)
from govoplan_core.core.automation import (
AutomationPrincipalRequest,
AutomationPrincipalResolution,
)
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory
@@ -558,6 +562,149 @@ class AccessApiPrincipalProvider:
)
class AccessAutomationPrincipalProvider:
"""Rebuild a trigger owner against current tenant authorization."""
def __init__(
self,
*,
idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> None:
self._idm_directory = idm_directory
self._identity_directory = identity_directory
self._organization_directory = organization_directory
def resolve_automation_principal(
self,
session: object,
*,
request: AutomationPrincipalRequest,
) -> AutomationPrincipalResolution:
if not isinstance(session, Session):
raise TypeError(
"Access automation principal resolution requires a "
"SQLAlchemy session"
)
account = session.get(Account, request.account_id)
user = session.get(User, request.membership_id)
tenant = session.get(Tenant, request.tenant_id)
if (
account is None
or user is None
or tenant is None
or not account.is_active
or not user.is_active
or not tenant.is_active
or user.account_id != account.id
or user.tenant_id != tenant.id
):
return AutomationPrincipalResolution(
allowed=False,
reason=(
"The automation owner is inactive, missing, or no longer "
"belongs to the tenant."
),
missing_scopes=tuple(sorted(set(request.grant_scopes))),
provenance={
"authorization_ref": request.authorization_ref,
"tenant_id": request.tenant_id,
"account_id": request.account_id,
"membership_id": request.membership_id,
"status": "inactive_or_inconsistent",
},
)
idm_assignments, idm_roles = _principal_idm_context(
session,
user=user,
account=account,
tenant_id=request.tenant_id,
idm_directory=self._idm_directory,
organization_directory=self._organization_directory,
)
authorization_context = collect_user_authorization_context(
session,
user,
account=account,
include_system=False,
extra_roles=idm_roles,
)
current_scopes = authorization_context.scopes
granted_scopes = tuple(
sorted(
{
required
for required in request.grant_scopes
if scopes_grant_compatible(current_scopes, required)
}
)
)
missing_scopes = tuple(
sorted(set(request.grant_scopes) - set(granted_scopes))
)
if missing_scopes:
return AutomationPrincipalResolution(
allowed=False,
reason=(
"The automation owner no longer has every scope granted "
"to this trigger."
),
granted_scopes=granted_scopes,
missing_scopes=missing_scopes,
provenance={
"authorization_ref": request.authorization_ref,
"tenant_id": request.tenant_id,
"account_id": request.account_id,
"membership_id": request.membership_id,
"status": "authorization_reduced",
},
)
principal_ref = _build_principal_ref(
session,
account=account,
user=user,
tenant_id=request.tenant_id,
scopes=list(granted_scopes),
auth_method="service_account",
idm_assignments=idm_assignments,
identity_directory=self._identity_directory,
extra_roles=idm_roles,
authorization_context=authorization_context,
include_system_roles=False,
)
principal_ref = replace(
principal_ref,
service_account_id=request.authorization_ref,
acting_for_account_id=account.id,
)
api_principal = _api_principal_from_ref(
session,
principal_ref,
permission_evaluator=LegacyPermissionEvaluator(),
)
return AutomationPrincipalResolution(
allowed=True,
principal=api_principal,
granted_scopes=granted_scopes,
provenance={
"authorization_ref": request.authorization_ref,
"tenant_id": request.tenant_id,
"account_id": request.account_id,
"membership_id": request.membership_id,
"role_ids": sorted(principal_ref.role_ids),
"group_ids": sorted(principal_ref.group_ids),
"function_assignment_ids": sorted(
principal_ref.function_assignment_ids
),
"delegation_ids": sorted(principal_ref.delegation_ids),
"status": "current_authorization_resolved",
},
)
def get_api_principal(
request: Request,
session: Session = Depends(get_session),

View File

@@ -15,6 +15,7 @@ from govoplan_core.core.access import (
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
@@ -41,6 +42,7 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
from govoplan_core.core.views import ViewSurface
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
@@ -71,6 +73,8 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission("access:system_role:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "Access", "system"),
_permission("access:system_setting:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "Access", "system"),
_permission("access:system_setting:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "Access", "system"),
_permission("access:system_credential:read", "View system credentials", "List instance-wide reusable credential envelopes without revealing secret values.", "Access", "system"),
_permission("access:system_credential:write", "Manage system credentials", "Create, update, and retire instance-wide reusable credential envelopes.", "Access", "system"),
_permission("access:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "Access", "system"),
_permission("access:audit:read", "View system audit", "Read audit records across tenants.", "Access", "system"),
_permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"),
@@ -91,6 +95,9 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
_permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"),
_permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"),
_permission("access:credential:read", "View credentials", "List reusable credential envelopes without revealing secret values.", "Tenant access", "tenant"),
_permission("access:credential:write", "Manage credentials", "Create, update, and retire reusable credential envelopes.", "Tenant access", "tenant"),
_permission("access:credential:manage_own", "Manage own credentials", "Manage reusable credentials owned by the current membership.", "Tenant access", "tenant"),
_permission("access:policy:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant access", "tenant"),
_permission("access:policy:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant access", "tenant"),
_permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"),
@@ -125,6 +132,8 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
"access:system_role:assign",
"access:system_setting:read",
"access:system_setting:write",
"access:system_credential:read",
"access:system_credential:write",
"access:governance:read",
"access:governance:write",
),
@@ -185,6 +194,8 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
"access:api_key:revoke",
"access:setting:read",
"access:setting:write",
"access:credential:read",
"access:credential:write",
"access:policy:read",
"access:policy:write",
),
@@ -244,6 +255,10 @@ ADMIN_READ_SCOPES = (
"access:account:read",
"access:governance:read",
"access:function:read",
"views:definition:read",
"views:assignment:read",
"views:system_definition:read",
"views:system_assignment:read",
)
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
@@ -479,6 +494,18 @@ def _api_principal_provider(context: ModuleContext) -> object:
return AccessApiPrincipalProvider()
def _automation_principal_provider(context: ModuleContext) -> object:
from govoplan_access.backend.auth.dependencies import (
AccessAutomationPrincipalProvider,
)
return AccessAutomationPrincipalProvider(
idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _tenant_context_switcher(context: ModuleContext) -> object:
del context
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
@@ -612,6 +639,10 @@ manifest = ModuleManifest(
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(
name="auth.automation_principal",
version="0.1.0",
),
),
permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES,
@@ -650,9 +681,28 @@ manifest = ModuleManifest(
package_name="@govoplan/access-webui",
routes=(FrontendRoute(path="/admin", component="AdminPage", required_any=ADMIN_READ_SCOPES, order=900),),
nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
view_surfaces=(
ViewSurface(id="access.admin.system-tenants", module_id="access", kind="section", label="System tenants", order=10),
ViewSurface(id="access.admin.system-roles", module_id="access", kind="section", label="System roles", order=20),
ViewSurface(id="access.admin.system-users", module_id="access", kind="section", label="System users", order=50),
ViewSurface(id="access.admin.system-credentials", module_id="access", kind="section", label="System credentials", order=80),
ViewSurface(id="access.admin.tenant-roles", module_id="access", kind="section", label="Tenant roles", order=10),
ViewSurface(id="access.admin.tenant-function-mappings", module_id="access", kind="section", label="Function mappings", order=20),
ViewSurface(id="access.admin.tenant-groups", module_id="access", kind="section", label="Tenant groups", order=30),
ViewSurface(id="access.admin.tenant-users", module_id="access", kind="section", label="Tenant users", order=40),
ViewSurface(id="access.admin.tenant-credentials", module_id="access", kind="section", label="Tenant credentials", order=70),
ViewSurface(id="access.admin.tenant-api-keys", module_id="access", kind="section", label="Tenant API keys", order=80),
ViewSurface(id="access.admin.tenant-settings", module_id="access", kind="section", label="Tenant settings", order=90),
ViewSurface(id="access.admin.group-credentials", module_id="access", kind="section", label="Group credentials", order=30),
ViewSurface(id="access.admin.user-credentials", module_id="access", kind="section", label="User credentials", order=30),
ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30),
),
),
capability_factories={
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
_automation_principal_provider
),
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher,

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.auth.dependencies import (
AccessAutomationPrincipalProvider,
)
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import Account, User
from govoplan_access.backend.manifest import manifest
from govoplan_access.backend.security.sessions import (
UserAuthorizationContext,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
from govoplan_core.core.automation import AutomationPrincipalRequest
from govoplan_core.tenancy.scope import (
Tenant,
create_scope_tables,
scope_registry,
)
class AutomationPrincipalTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
AccessBase.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.account = Account(
id="account-1",
email="owner@example.test",
normalized_email="owner@example.test",
)
self.tenant = Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
)
self.user = User(
id="user-1",
tenant_id=self.tenant.id,
account_id=self.account.id,
email=self.account.email,
)
self.session.add_all([self.tenant, self.account, self.user])
self.session.commit()
self.provider = AccessAutomationPrincipalProvider()
def tearDown(self) -> None:
self.session.close()
AccessBase.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _request(self) -> AutomationPrincipalRequest:
return AutomationPrincipalRequest(
tenant_id=self.tenant.id,
account_id=self.account.id,
membership_id=self.user.id,
authorization_ref="dataflow-trigger:1",
grant_scopes=(
"dataflow:pipeline:run",
"datasources:catalogue:read",
),
)
def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None:
context = UserAuthorizationContext(
tenant_roles=[],
system_roles=[],
groups=[],
function_assignment_ids=(),
function_delegation_ids=(),
scopes=[
"dataflow:pipeline:run",
"datasources:catalogue:read",
"system:settings:write",
],
)
with patch(
"govoplan_access.backend.auth.dependencies."
"collect_user_authorization_context",
return_value=context,
):
result = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertTrue(result.allowed)
self.assertIsInstance(result.principal, ApiPrincipal)
self.assertEqual(
frozenset(
{
"dataflow:pipeline:run",
"datasources:catalogue:read",
}
),
result.principal.scopes,
)
self.assertEqual(
"dataflow-trigger:1",
result.principal.principal.service_account_id,
)
self.assertNotIn(
"system:settings:write",
result.principal.scopes,
)
def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None:
context = UserAuthorizationContext(
tenant_roles=[],
system_roles=[],
groups=[],
function_assignment_ids=(),
function_delegation_ids=(),
scopes=["dataflow:pipeline:run"],
)
with patch(
"govoplan_access.backend.auth.dependencies."
"collect_user_authorization_context",
return_value=context,
):
result = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertFalse(result.allowed)
self.assertEqual(
("datasources:catalogue:read",),
result.missing_scopes,
)
self.account.is_active = False
self.session.flush()
suspended = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertFalse(suspended.allowed)
self.assertEqual(
"inactive_or_inconsistent",
suspended.provenance["status"],
)
def test_manifest_registers_automation_resolution(self) -> None:
self.assertIn(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
manifest.capability_factories,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -12,6 +12,7 @@ import type {
} from "@govoplan/core-webui";
import { fetchShellAuth } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui";
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
import SystemUsersPanel from "./SystemUsersPanel";
@@ -25,7 +26,14 @@ import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPan
import ApiKeysPanel from "./ApiKeysPanel";
import FileConnectorsPanel from "./FileConnectorsPanel";
import MailProfilesPanel from "./MailProfilesPanel";
import { usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel";
import {
isViewSurfaceVisible,
useEffectiveView,
usePlatformUiCapabilities,
usePlatformUiCapability,
useViewSurfaces
} from "@govoplan/core-webui";
type AdminSection = string;
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
@@ -43,6 +51,7 @@ const handledAdminSectionIds = new Set<string>([
"system-users",
"system-file-connectors",
"system-mail-servers",
"system-credentials",
"tenant-settings",
"tenant-roles",
"tenant-function-role-mappings",
@@ -50,13 +59,38 @@ const handledAdminSectionIds = new Set<string>([
"tenant-users",
"tenant-file-connectors",
"tenant-mail-servers",
"tenant-credentials",
"tenant-api-keys",
"tenant-group-file-connectors",
"tenant-group-mail-servers",
"tenant-group-credentials",
"tenant-user-file-connectors",
"tenant-user-mail-servers"
"tenant-user-mail-servers",
"tenant-user-credentials"
]);
const builtInAdminSurfaceIds: Record<string, string> = {
"system-tenants": "access.admin.system-tenants",
"system-roles": "access.admin.system-roles",
"system-users": "access.admin.system-users",
"system-credentials": "access.admin.system-credentials",
"tenant-roles": "access.admin.tenant-roles",
"tenant-function-role-mappings": "access.admin.tenant-function-mappings",
"tenant-groups": "access.admin.tenant-groups",
"tenant-users": "access.admin.tenant-users",
"tenant-credentials": "access.admin.tenant-credentials",
"tenant-api-keys": "access.admin.tenant-api-keys",
"tenant-settings": "access.admin.tenant-settings",
"tenant-group-credentials": "access.admin.group-credentials",
"tenant-user-credentials": "access.admin.user-credentials",
"system-mail-servers": "mail.admin.system-servers",
"tenant-mail-servers": "mail.admin.tenant-servers",
"tenant-group-mail-servers": "mail.admin.group-servers",
"tenant-user-mail-servers": "mail.admin.user-servers",
"tenant-group-file-connectors": "files.admin.group-connectors",
"tenant-user-file-connectors": "files.admin.user-connectors"
};
export default function AdminPage({
settings,
auth,
@@ -70,14 +104,23 @@ export default function AdminPage({
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
const effectiveView = useEffectiveView();
const viewSurfaces = useViewSurfaces();
const mailProfilesAvailable = Boolean(mailProfilesUi);
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
const contributedSections = useMemo(
() =>
adminSectionCapabilities
.flatMap((capability) => capability.sections)
.filter((section) =>
isViewSurfaceVisible(
effectiveView,
section.surfaceId,
viewSurfaces
)
)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
[adminSectionCapabilities]
[adminSectionCapabilities, effectiveView, viewSurfaces]
);
const contributionById = useMemo(() => {
const mapped = new Map<string, AdminSectionContribution>();
@@ -95,6 +138,9 @@ export default function AdminPage({
if (hasScope(auth, "system:settings:read")) {
if (mailProfilesAvailable) sections.add("system-mail-servers");
}
if (hasAnyScope(auth, ["system:settings:read", "access:system_credential:read"])) {
sections.add("system-credentials");
}
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
@@ -113,8 +159,21 @@ export default function AdminPage({
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
}
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
return sections;
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
sections.add("tenant-credentials");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials");
}
return new Set(
[...sections].filter((sectionId) =>
isViewSurfaceVisible(
effectiveView,
builtInAdminSurfaceIds[sectionId],
viewSurfaces
)
)
);
}, [auth, contributedSections, effectiveView, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, viewSurfaces]);
const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section") as AdminSection | null;
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
@@ -137,11 +196,13 @@ export default function AdminPage({
if (!hasAnyScope(auth, adminReadScopes)) {
return (
<PageScrollViewport>
<div className="content-pad">
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
</Card>
</div>
</PageScrollViewport>
);
}
@@ -170,6 +231,7 @@ export default function AdminPage({
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
visibleNavItem(available, "system-credentials", "i18n:govoplan-core.credentials.dd097a22", 80),
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
])
@@ -185,7 +247,8 @@ export default function AdminPage({
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70),
visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
])
@@ -197,6 +260,7 @@ export default function AdminPage({
sortNavItems([
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-group-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
])
)
@@ -207,6 +271,7 @@ export default function AdminPage({
sortNavItems([
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-user-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
])
)
@@ -224,6 +289,13 @@ export default function AdminPage({
{!contributedSection && active === "system-mail-servers" && (
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
)}
{!contributedSection && active === "system-credentials" && (
<CredentialEnvelopesPanel
settings={settings}
scopeType="system"
canWrite={hasAnyScope(auth, ["system:settings:write", "access:system_credential:write"])}
/>
)}
{!contributedSection && active === "system-tenants" && (
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
)}
@@ -245,8 +317,11 @@ export default function AdminPage({
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="tenant" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
{!contributedSection && active === "tenant-user-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
{!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}

View File

@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import {
AdminPageLayout,
CredentialEnvelopeManager,
adminErrorMessage,
useDeltaWatermarks,
type ApiSettings,
type CredentialEnvelopeTargetOption,
type MailProfileScope
} from "@govoplan/core-webui";
import {
fetchGroupsDelta,
fetchUsersDelta,
type GroupSummary,
type UserAdminItem
} from "../../api/admin";
import { loadDeltaRows } from "./utils/deltaRows";
type ScopeType = Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
export default function CredentialEnvelopesPanel({
settings,
scopeType,
canWrite
}: {
settings: ApiSettings;
scopeType: ScopeType;
canWrite: boolean;
}) {
const [targets, setTargets] = useState<CredentialEnvelopeTargetOption[]>([]);
const [loadingTargets, setLoadingTargets] = useState(
scopeType === "user" || scopeType === "group"
);
const [targetError, setTargetError] = useState("");
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } =
useDeltaWatermarks();
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [
resetDeltaWatermark,
scopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
setLoadingTargets(true);
setTargetError("");
try {
if (scopeType === "user") {
const users = await loadDeltaRows(
usersRef.current,
"access:credential-users",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchUsersDelta(settings, { since }),
(response) => response.users,
(user) => user.id,
"access_user",
(left, right) => left.email.localeCompare(right.email)
);
usersRef.current = users;
setTargets(
users.map((user) => ({
id: user.id,
label: user.display_name || user.email,
secondary: user.display_name ? user.email : null
}))
);
} else {
const groups = await loadDeltaRows(
groupsRef.current,
"access:credential-groups",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchGroupsDelta(settings, { since }),
(response) => response.groups,
(group) => group.id,
"access_group",
(left, right) =>
left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug)
);
groupsRef.current = groups;
setTargets(
groups.map((group) => ({
id: group.id,
label: group.name,
secondary: group.slug
}))
);
}
} catch (err) {
setTargets([]);
setTargetError(adminErrorMessage(err));
} finally {
setLoadingTargets(false);
}
}
return (
<AdminPageLayout
title={scopeTitle(scopeType)}
description={scopeDescription(scopeType)}
loading={loadingTargets}
error={targetError}
>
<CredentialEnvelopeManager
settings={settings}
scopeType={scopeType}
targetOptions={targets}
targetLabel={scopeType === "group" ? "Group" : "User"}
title="Reusable credentials"
canWrite={canWrite}
/>
</AdminPageLayout>
);
}
function scopeTitle(scopeType: ScopeType): string {
if (scopeType === "system") return "System credentials";
if (scopeType === "tenant") return "Tenant credentials";
if (scopeType === "group") return "Group credentials";
return "User credentials";
}
function scopeDescription(scopeType: ScopeType): string {
if (scopeType === "system") {
return "Instance credentials that can be inherited by tenants and limited to selected modules or servers.";
}
if (scopeType === "tenant") {
return "Tenant credentials shared by Mail, Files, Calendar, Addresses, and other permitted modules.";
}
return `Reusable credentials owned by the selected ${scopeType}.`;
}

View File

@@ -10,6 +10,23 @@ const translations = {
de: generatedTranslations.de
};
const accessAdminSurfaces = [
{ id: "access.admin.system-tenants", moduleId: "access", kind: "section" as const, label: "System tenants", order: 10 },
{ id: "access.admin.system-roles", moduleId: "access", kind: "section" as const, label: "System roles", order: 20 },
{ id: "access.admin.system-users", moduleId: "access", kind: "section" as const, label: "System users", order: 50 },
{ id: "access.admin.system-credentials", moduleId: "access", kind: "section" as const, label: "System credentials", order: 80 },
{ id: "access.admin.tenant-roles", moduleId: "access", kind: "section" as const, label: "Tenant roles", order: 10 },
{ id: "access.admin.tenant-function-mappings", moduleId: "access", kind: "section" as const, label: "Function mappings", order: 20 },
{ id: "access.admin.tenant-groups", moduleId: "access", kind: "section" as const, label: "Tenant groups", order: 30 },
{ id: "access.admin.tenant-users", moduleId: "access", kind: "section" as const, label: "Tenant users", order: 40 },
{ id: "access.admin.tenant-credentials", moduleId: "access", kind: "section" as const, label: "Tenant credentials", order: 70 },
{ id: "access.admin.tenant-api-keys", moduleId: "access", kind: "section" as const, label: "Tenant API keys", order: 80 },
{ id: "access.admin.tenant-settings", moduleId: "access", kind: "section" as const, label: "Tenant settings", order: 90 },
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "Group credentials", order: 30 },
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "User credentials", order: 30 },
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "Personal credentials", order: 30 }
];
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
if (!onAuthChange) {
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
@@ -22,6 +39,7 @@ export const accessModule: PlatformWebModule = {
label: "i18n:govoplan-access.access.2f81a22d",
version: "1.0.0",
translations,
viewSurfaces: accessAdminSurfaces,
navItems: [
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],