Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions docs/admin/applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,23 @@ you also need to:
provision a resource server
- create at least one role + group that connects users to the app

## Cloning an app

The slug is immutable, so the way to stand up a near-identical app — or to
effectively **rename** one — is to clone it. In the list, right-click a row →
**Clone**. The Create modal opens pre-filled from the source:

- **Slug** is left blank — give the copy its own (the source's can't be reused).
- **Display name, description and the whole permission catalog** are copied. The
catalog entries are copied as *new* entries (fresh ids), so the source app's
role grants and resource-server subsets are left untouched.
- **Settings** are copied too — branding, registration, native-grant / DCR / CIMD
overrides — **except the Origin subdomain**, which is globally unique and would
collide. Set a new subdomain on the copy if it needs one.

To rename: clone the app, give the copy the new slug, re-point the dependent
clients / scopes / APIs / roles / groups, then delete the original.

## Provisioning the resource server

Under [OAuth → APIs](./oauth-apis), create an OAuth API named after
Expand All @@ -109,9 +126,9 @@ Catalog entries can be edited any time, but:
## Application settings

Beyond the permission catalog, an Application can override a slice of the
realm's configuration and carry its own login experience. Open
**Settings** from the app's row in the list (the routed `settings/:id`
modal; disabled for the system apps). Everything here is **optional and
realm's configuration and carry its own login experience. Open the app
from the list and switch to the **Settings** tab (disabled for the system
apps). Everything here is **optional and
sparse** — an App overrides only what you switch on; anything left off
**inherits the realm** value, field by field. Clearing an override
re-inherits the realm.
Expand Down Expand Up @@ -200,8 +217,10 @@ pages (operational / GDPR), and the DCR garbage-collection interval (the
GC job iterates per realm) are **not** per-app overridable — set them in
[Realm settings](./realm-settings).

The same overrides are reachable over the API at
`GET`/`PATCH /api/app/{id}/settings` (`app:read` / `app:write`) — see the
An App is one resource, so these overrides ride **inline** on the app itself —
`GET /api/app/{id}` returns them, and `POST`/`PUT /api/app` write them in the
same call that creates or updates the app (`app:read` / `app:write`), in one
tenant transaction. There is no separate settings endpoint. See the
[Admin API reference](../reference/admin-api#application-settings).

## Relationships to other areas
Expand Down
4 changes: 4 additions & 0 deletions docs/admin/groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ Tab **Roles**: pick the roles the group should carry. A group can hold roles fro

Tab **Effective Members** shows the fully expanded list — direct members plus everyone reached through nested groups, with a "via" hint pointing at the first nested-group hop. Useful for sanity checks before granting a powerful role.

## Cloning a group

To make a near-identical group, right-click it in the list → **Clone**. The Create modal opens pre-filled: members, assigned roles, the membership type (manual or the auto-membership script), email mode and the `BoundTo` app list are all copied; only the **Name** is blank. A cloned auto-group starts without the source's last-evaluation error — it re-evaluates on first save.

## Deleting a group

List → right-click → **Delete**.
Expand Down
8 changes: 8 additions & 0 deletions docs/admin/oauth-apis.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ Changing the linked **Application** is allowed but be careful — the
RS's scope-resolution and the per-Audience `resource_access` shape
immediately switch to the new app context.

## Cloning an API

The **Name** (the audience) is immutable, so to make a near-identical
resource server, clone it. List → right-click → **Clone**. The Create
wizard opens pre-filled — display name, description, scopes, user claims,
the linked Application and its catalog subset are copied; only **Name** is
blank. API secrets are not copied; the copy starts with none.

## Deleting

List → right-click → **Delete**. Soft-deleted; the OAuth API is no
Expand Down
4 changes: 4 additions & 0 deletions docs/admin/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ Open a client by double-click. Most fields can be edited live; **Client ID** is

The **Regenerate Secret** button at the bottom rotates the client secret. Old secret stops working immediately, new one is shown once — copy it now.

## Cloning a client

**Client ID** is immutable, so to stand up a near-identical client — or to effectively rename one — clone it. List → right-click → **Clone**. The Create modal opens pre-filled: scopes, grants, redirect URIs, app links, token lifetimes and the rest are copied; only **Client ID** is blank (enter a new one). The **client secret is not copied** — a fresh one is generated on create and shown once, exactly as for a brand-new client. DCR registration metadata and any Service-Account link are dropped, so the copy is a plain admin-created client.

## Deleting

List → right-click → **Delete**. Soft-deleted entries can still be queried for audit purposes but are excluded from the OAuth flow.
Expand Down
4 changes: 4 additions & 0 deletions docs/admin/oauth-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Hiding scopes from discovery is defense-in-depth. An attacker can still try arbi

In the [OAuth client](./oauth-clients) → tab **Scopes** → add the new scope to "Allowed scopes". Only then may the client include it in its authorisation request.

## Cloning a scope

Scope **Name** is immutable, so to make a variant of an existing scope, clone it. List → right-click → **Clone**. The Create modal opens pre-filled — display name, description, resources, user claims, the app binding and all the flags are copied; only **Name** is blank. A standard OIDC scope can be cloned too — the copy is an ordinary editable scope.

## Deleting a scope

List → right-click → **Delete** (soft delete).
Expand Down
4 changes: 4 additions & 0 deletions docs/admin/roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ modgud:authorization-group:read

Fully-qualified strings (containing `:`) pass through the resolver unchanged. The seeded System Admin / User Manager / Viewer roles are built exactly this way.

## Cloning a role

To make a variant of a role — say a tighter copy of an existing one — right-click it in the list → **Clone**. The Create modal opens pre-filled: the linked Application, the selected permission subset and the realm-admin flag are copied; only the **Name** is blank. Give the copy a new name, adjust the permission selection, and create.

## Cross-app roles (special case)

A role can also include fully-qualified permissions from **other** apps in its permissions list — for example a "Cross-App Auditor" with `modgud:auth-log:read` AND `acme-tasks:audit:read`. This works because fully-qualified permissions pass through without further filtering.
Expand Down
33 changes: 21 additions & 12 deletions docs/reference/admin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ behind the `realm:admin` bypass.

The SPA bootstrap reads public realm/branding metadata anonymously.
Realm-wide admin-owned config lives under `/api/realm-settings`; the
**per-Application** override of that config lives under
`/api/app/{id}/settings` (below).
**per-Application** override of that config rides inline on the App
resource (see [Application settings](#application-settings) below).

| Method | Path | Permission |
|---|---|---|
Expand All @@ -224,19 +224,28 @@ the identity inputs the realm or App requires. See

## Application settings

Per-Application override of the realm defaults (ADR-0011). The
`ApplicationSettings` document is keyed by the App's id and tenant-scoped.
**Sparse** in both directions: `GET` returns only what the App overrides
(a `null` section = inherits the realm); `PATCH` replaces the provided
sections (a `null`/omitted section = no change, and within a provided
section a `null` field = inherit the realm value). Setting
`Origin.Subdomain` also writes the global host→App routing map (and clearing
it removes the route). See [Applications](/admin/applications#application-settings).
Per-Application override of the realm defaults (ADR-0011). An App is **one
resource**, so this override is not a separate endpoint — it rides **inline**
on the App as a `Settings` field, read and written in the same call as the
rest of the App (one tenant transaction).

- **Read** — `GET /api/app/{id}` returns `Settings` next to the catalog. It is
**sparse**: only the sections the App overrides are present; a `null`/absent
section inherits the realm. (The list endpoint `GET /api/app` omits it.)
- **Write** — `POST /api/app` and `PUT /api/app/{id}` carry `Settings` as the
**complete desired override state** (a replace, not a merge): a present
section sets that override, a `null` section clears it (→ inherit the realm),
and within a section a `null` field inherits that field. Setting
`Origin.Subdomain` also writes the global host→App routing map (clearing it
removes the route).

See [Applications](/admin/applications#application-settings).

| Method | Path | Permission |
|---|---|---|
| `GET` | `/api/app/{id}/settings` | `app:read` |
| `PATCH` | `/api/app/{id}/settings` | `app:write` |
| `GET` | `/api/app/{id}` | `app:read` |
| `POST` | `/api/app` | `app:write` |
| `PUT` | `/api/app/{id}` | `app:write` |

The body sections are `Origin` (subdomain), `Branding`, `EmailBranding`,
`SelfRegistration` (incl. the `Posture` = `Off` / `JitOnOtp` /
Expand Down
1 change: 1 addition & 0 deletions src/frontend-vue/public/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"apply": "Übernehmen",
"delete": "Löschen",
"open": "Öffnen",
"clone": "Klonen",
"close": "Schließen",
"cancel": "Abbrechen",
"back": "Zurück",
Expand Down
167 changes: 167 additions & 0 deletions src/frontend-vue/src/composables/useClone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Generic "Clone / Copy" affordance for admin entities.
*
* Slugs, client_ids and audiences are immutable by design — so the way to
* "rename" an entity is to clone it under a new identity and re-point the
* references. This composable is the plumbing for that: a List stages a
* prefill (the source entity with its immutable identity blanked and its
* server-issued secrets dropped) and navigates to the entity's Create modal;
* the Create modal consumes the prefill on mount and maps it into its form.
*
* The fragment-routed modals only carry an `id` slot in the URL (`create`),
* so the prefill rides out-of-band through this module-level stash instead of
* the route. `consume()` is single-use (it clears on read) and keyed by entity
* so a stale stash can never bleed into a different entity's Create modal.
*/
import { ref } from 'vue'

interface CloneStash {
entity: string
prefill: Record<string, unknown>
}

// Module-level singleton: one pending clone at a time. Staging overwrites;
// consuming clears. A normal (non-clone) Create simply finds nothing.
const pending = ref<CloneStash | null>(null)

export function useClone() {
/** Stash a prefill for the given entity, to be picked up by its Create modal. */
function stage(entity: string, prefill: Record<string, unknown>): void {
pending.value = { entity, prefill }
}

/**
* Pop the staged prefill if it belongs to `entity`, else return null. Reads
* are single-use — the stash is cleared so a re-opened blank Create starts
* empty.
*/
function consume<T = Record<string, unknown>>(entity: string): T | null {
const p = pending.value
if (!p || p.entity !== entity) return null
pending.value = null
return p.prefill as T
}

/** Drop any pending stash (e.g. on an aborted flow). */
function clear(): void {
pending.value = null
}

return { stage, consume, clear }
}

/**
* Per-entity clone rule. Everything not named here is copied 1:1 from the
* source DTO; `blank` fields are reset to '' (the immutable identity the admin
* must re-enter), `drop` fields are removed entirely (secrets, server-issued
* ids), and `reshape` runs last for entity-specific shaping.
*/
export interface CloneDescriptor {
/** Identity fields reset to '' — the Create form re-validates them. */
blank?: string[]
/** Fields removed entirely (secrets, ids belonging to the source's streams). */
drop?: string[]
/** Final entity-specific reshape. */
reshape?: (clone: Record<string, unknown>) => Record<string, unknown>
}

/**
* Build a clone prefill from a full source DTO. The result is a detached deep
* copy shaped exactly like the source DTO (so the Create modal's existing
* `fromDto` can consume it), with identity blanked and secrets dropped.
*/
export function buildClonePrefill<T extends object>(
source: T,
descriptor: CloneDescriptor,
): Record<string, unknown> {
// JSON round-trip rather than structuredClone: the source is often a Pinia
// store entity (a Vue reactive Proxy), which structuredClone rejects with a
// DataCloneError. The DTOs are pure JSON (no Dates / Sets / functions), so
// this is a faithful detached deep copy and serialises straight through the
// reactive proxy.
const clone = JSON.parse(JSON.stringify(source)) as Record<string, unknown>
for (const key of descriptor.blank ?? []) clone[key] = ''
for (const key of descriptor.drop ?? []) delete clone[key]
return descriptor.reshape ? descriptor.reshape(clone) : clone
}

// ── Per-entity descriptors ──────────────────────────────────────────────────
// The `entity` string is an internal match key shared between a List's stage()
// and its Create modal's consume() — it need only be stable, not user-facing.

export const APP_CLONE = {
entity: 'app',
descriptor: {
blank: ['Slug'],
drop: ['Id', 'IsSystem'],
reshape: (c) => {
// Catalog entries carry server-issued ids that belong to the SOURCE
// app's event streams — null them so the clone mints fresh ids under
// its own streams (role-grants / RS-subsets on the source are untouched).
if (Array.isArray(c.Permissions)) {
c.Permissions = (c.Permissions as Record<string, unknown>[]).map((p) => ({
...p,
Id: null,
}))
}
// Origin (subdomain) is globally unique — the clone can't claim the
// source's. Drop just that override; the rest of the ADR-0011 settings
// clone 1:1.
if (c.Settings && typeof c.Settings === 'object') {
c.Settings = { ...(c.Settings as Record<string, unknown>), Origin: null }
}
return c
},
} satisfies CloneDescriptor,
} as const

export const CLIENT_CLONE = {
entity: 'oauth-client',
descriptor: {
blank: ['ClientId'],
// Secret is hashed at rest (unclonable; create mints a fresh one). DCR
// audit fields + SA-linkage belong to the original registration only.
drop: [
'Id',
'ClientSecret',
'IsDynamicallyRegistered',
'DcrRegisteredAt',
'DcrRegisteredFromIp',
'DcrLastUsedAt',
'LinkedServiceAccountId',
],
} satisfies CloneDescriptor,
} as const

export const SCOPE_CLONE = {
entity: 'oauth-scope',
descriptor: {
blank: ['Name'],
drop: ['Id', 'IsStandard'],
} satisfies CloneDescriptor,
} as const

export const API_CLONE = {
entity: 'oauth-api',
descriptor: {
blank: ['Name'],
// Secrets are server-issued; a clone starts with none.
drop: ['Id', 'Secrets'],
} satisfies CloneDescriptor,
} as const

export const ROLE_CLONE = {
entity: 'role',
descriptor: {
blank: ['Name'],
drop: ['Id'],
} satisfies CloneDescriptor,
} as const

export const GROUP_CLONE = {
entity: 'group',
descriptor: {
blank: ['Name'],
drop: ['Id'],
} satisfies CloneDescriptor,
} as const
22 changes: 19 additions & 3 deletions src/frontend-vue/src/views/admin/apps/AppDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { useI18n } from '@cocoar/vue-localization'
import ModalLayout from '@/components/ModalLayout.vue'
import AppSettingsSections from './AppSettingsSections.vue'
import { useApplicationsStore } from '@/stores/applications.store'
import { useClone, APP_CLONE } from '@/composables/useClone'
import type {
ApplicationDto,
ApplicationPermissionInputDto,
ApplicationSettingsDto,
} from '@/models/application'

const { t } = useI18n()
Expand All @@ -21,6 +23,7 @@ const props = defineProps<{

const id = computed(() => props.id)
const store = useApplicationsStore()
const { consume } = useClone()
const isCreate = computed(() => id.value === 'create')
const loading = ref(false)
const error = ref<string | null>(null)
Expand Down Expand Up @@ -72,6 +75,9 @@ interface FormState {

const form = ref<FormState>({ Slug: '', DisplayName: '', Description: '' })
const dto = ref<ApplicationDto | null>(null)
// In clone-create mode the source's ADR-0011 settings (minus its globally-unique
// Origin) ride here so AppSettingsSections populates from them; null otherwise.
const clonePrefillSettings = ref<ApplicationSettingsDto | null>(null)

function fromDto(d: ApplicationDto): { form: FormState; catalog: CatalogRow[] } {
return {
Expand Down Expand Up @@ -256,8 +262,18 @@ const footerButton = computed(() => ({

onMounted(async () => {
if (isCreate.value) {
// Start with one empty row so a new App has somewhere to type into.
addRow()
// Clone: prefill identity + catalog (ids already nulled) + settings (Origin
// dropped) from the staged source; fall back to one empty row otherwise.
const clone = consume<ApplicationDto>(APP_CLONE.entity)
if (clone) {
const parsed = fromDto(clone)
form.value = parsed.form
catalog.value = parsed.catalog
clonePrefillSettings.value = clone.Settings ?? null
} else {
// Start with one empty row so a new App has somewhere to type into.
addRow()
}
return
}
loading.value = true
Expand Down Expand Up @@ -415,7 +431,7 @@ async function save() {

<!-- Tab: Settings (ADR-0011 per-App override) — one App, one modal -->
<div v-if="!isSystem" v-show="activeTab === 'settings'" class="tab-content">
<AppSettingsSections ref="settingsRef" :model-value="dto?.Settings" />
<AppSettingsSections ref="settingsRef" :model-value="isCreate ? clonePrefillSettings : dto?.Settings" />
</div>

<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
Expand Down
Loading
Loading