Simple Smooth Safe / DocGen / docs / tenant-setup ← All docs

SSS DocGen — Tenant Setup Runbook

For the administrators who will stand DocGen up in your own Azure subscription and Power Platform tenant. Everything runs in your environment; nothing is hosted by Simple Smooth Safe.

Written to be followed from the Azure and Power Platform portals, or with the az CLI. Scripted equivalents live in infra/ if you would rather automate.


1. Who needs to do what

Three roles are involved, and it is worth confirming who holds each before starting — the most common delay is discovering mid-setup that nobody present can consent to an app registration.

Step Requires
Create the Entra app registration and secret Application Administrator (or Global Administrator) in Entra
Create Azure resources and role assignments Contributor + User Access Administrator on the target subscription or resource group
Import the solution, create the application user, assign the role System Administrator in the target Dataverse environment

Prerequisites

  • An Azure subscription, and a resource group you are willing to deploy into
  • A Power Platform environment with Dataverse
  • The DocGen solution package and container image, supplied by Simple Smooth Safe
  • Decide your region now — it is a deployment parameter, and it determines data residency

2. What gets created in your tenant

Nothing here is a black box; each item exists for one reason.

Resource Why it exists
Entra app registration One identity used for two things: validating inbound calls to the rendering service, and authenticating that service to Dataverse
Key Vault Holds the app registration's client secret so it is not stored in the container definition
Container Registry (ACR) Holds the rendering service image. Container Apps pulls from here at startup
Container Apps environment + app Runs the rendering service. Scales to zero when idle
Log Analytics workspace Container logs, 30-day retention
Managed identity (system-assigned) Lets the container app pull its image and read the Key Vault secret without a second credential. It has no Dataverse access
Dataverse solution The custom API, the plugin, two configuration tables, a security role, the command-bar button
Dataverse application user The identity the rendering service uses to query your data

3. The Entra app registration

This is the step that most often goes wrong, and the audience setting in 3.3 is why. Read it before you start clicking.

3.1 Create the application

Single-tenant. It must not be multi-tenant — DocGen is not a multi-tenant service, and widening the audience adds risk for no benefit.

az ad app create --display-name "SSS DocGen" --sign-in-audience AzureADMyOrg

Record the resulting Application (client) ID and your Directory (tenant) ID.

3.2 Set the Application ID URI

az ad app update --id <client-id> --identifier-uris "api://<client-id>"

3.3 Request access-token version 2 — and the audience trap

Set the application to issue v2 access tokens:

az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/<app-object-id>" \
  --headers "Content-Type=application/json" \
  --body '{"api":{"requestedAccessTokenVersion":2}}'

The trap. With v2 access tokens the aud claim is the bare client-id GUID, not api://<client-id>. The rendering service must therefore be configured with the bare GUID as its expected audience. Configure api://<client-id> and every call fails authentication with no obvious explanation. If you are debugging a persistent 401, check this first.

The token scope requested by the plugin is a different string and does use the URI form: api://<client-id>/.default. Both are correct; they are not interchangeable.

3.4 Create the service principal and a client secret

az ad sp create --id <client-id>
az ad app credential reset --id <client-id> --display-name docgen

Capture the secret value immediately — it cannot be retrieved later. You will store it in two places (§5.4 explains why), so keep it to hand until both are done.

Set a rotation reminder now. Rotating means resetting the credential and updating both copies together.

3.5 If you will run the supplied CI workflows

The deploy workflow authenticates to Azure with OIDC, not a stored secret. That requires a federated credential on an app registration, scoped to your repository and environment (repo:<org>/<repo>:environment:<env>), granted Contributor on the resource group and AcrPush on the registry.

The setup scripts do not create this — it is a manual step. The workflow references three repository secrets (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID); if they are unset, azure/login fails in under a second with "Not all values are present" and every subsequent step is skipped.

Also check the job's GitHub environment. If the secrets exist at repository level but the environment holds empty copies of the same names, the environment's values win and login keeps failing.


4. Azure resources

Deploy with the supplied Bicep template, or create the equivalents by hand. Either way:

  1. Build and push the image first. The container app cannot start without an image in the registry, so image build must precede infrastructure deployment.
  2. Deploy the infrastructure.
  3. Grant the container app's managed identity access to Key Vault.

First-deploy ordering trap. The container app is created with a system-assigned identity, but that identity does not exist until the app is created — so it cannot have been granted Key Vault Secrets User beforehand. The first deployment can therefore fail while the app tries to resolve its secret. This is expected: grant the role to the principal id from the deployment output, then re-run the deployment. It succeeds the second time.

AcrPull on the registry is assigned by the template. Confirm both role assignments exist before moving on.

Sizing

Defaults are 1 vCPU / 2 GiB (headroom for the PDF renderer), scale-to-zero with a maximum of 2 replicas and 20 concurrent requests each. Scale-to-zero means the first request after an idle period waits roughly 10–20 seconds while the container starts. If that is unacceptable for interactive use, raise the minimum replica count — at a cost, since the container then runs continuously.


5. Dataverse

5.1 Import the solution

Import the supplied managed solution package through the Power Platform admin centre or pac.

Do not attempt to import the hand-authored solution source from the repository directly. It is unpacked output rather than an importable artifact, and doing so hits a publishing race documented in the deployment runbook. Use the packaged solution, or create the schema via the metadata API.

5.2 Create the application user

In the Power Platform admin centre, add an application user for the app registration from §3, using its client id.

5.3 Assign a security role

The solution ships a role named SSS DocGen API, now reduced from 258 privileges to 99: every Write, Delete, Assign and Share on business data has been removed, and all reads are retained.

Read this before assigning it. The role has two known problems, and the second will stop you.

1. It still grants more than the rendering service alone needs. Create/Write/Delete on the two DocGen configuration tables are retained deliberately, because the documented install assigns this same role to the identity that runs the template seeding tool. If you give the seeder its own app registration, you can drop those and make the configuration tables read-only.

2. It is missing privileges for the Practice Pro (ts_*) model entirely. The role contains no ts_* privileges at all, no prvReadContact, and no prvReadInvoiceDetail. Consequences:

If your model is Then
Unit4 PSA (invoice, psa_*) Works, except the psa-invoice line-items query needs prvReadInvoiceDetail added
Practice Pro (ts_*) Every template fails at its first query. You must add read on ts_invoice, ts_invoiceitem, ts_invoiceitemsummary, ts_engagement, ts_engagementtask, ts_serviceline, ts_paymentterm, ts_staff, ts_timeentry, ts_expenseentry, ts_expensecategory, ts_artransaction, ts_arbatch, plus account and contact, and AppendTo on ts_invoice if you use Attach=true

Generate these from your own environment's metadata rather than copying names from anywhere: privilege names use Dataverse schema-name casing, which is inconsistent even within one publisher (prvReadpsa_Invoice but prvReadpsa_expenses). A misspelled privilege name is silently not granted.

Do not combine both models into one role. A privilege naming a table your environment does not have will fail the import. Use one role per model.

The privileges most easily dropped by mistake are prvAppendNote and prvAppendTo<target>. Dataverse needs both to attach a note to a record, and an earlier version of this document listed only "Create on Note" — which is wrong. Worse, the health check in §6 does not exercise Attach=true, so a role missing them passes the health check and then fails the first time someone stores a document. Test Attach=true explicitly.

5.4 Set the five environment variables

Variable Value
sss_DocGenApiBaseUrl The container app's HTTPS URL
sss_DocGenTenantId Your directory (tenant) id
sss_DocGenClientId The application (client) id from §3.1
sss_DocGenClientSecret The client secret from §3.4 — as a Text variable
sss_DocGenScope api://<client-id>/.default

Why the secret is plain text, and what to do about it. The plugin reads its configuration with a Dataverse request that does not resolve Key Vault–backed secret variables, so a secret-typed variable would come back empty. Until that is changed, restrict read access to environment variable values, because anyone holding it can read this secret. This is a known, deferred limitation rather than an oversight, and it is covered in the security & compliance review (available on request).

5.5 Enable notes on your target tables

Any table you generate documents for must have notes (attachments) enabled, or storing a document fails with 0x80040205. This is a table setting in the maker portal and cannot be changed by the solution.

5.6 Register the command-bar button (optional)

The solution ships one button, on the invoice table. The underlying script is table-agnostic — it discovers which templates target the current table — so adding it elsewhere is a maker-portal command definition pointing at the same web resource and function. If your environment has no invoice table, the import may warn; the runtime still installs correctly and you add the button to a table you do have.


6. Verify

  1. Service health: the rendering service exposes an unauthenticated health endpoint that should return {"status":"ok"}.
  2. End-to-end: invoke sss_GenerateDocument with a template code and a real record id. A successful base64 PDF begins with JVBER (that is %PDF encoded) — the supplied health-check tool asserts exactly this.
  3. Generate with Attach=true as a separate test. The health check does not pass Attach, so it never exercises note creation — the one path that depends on the Append/AppendTo privilege pair discussed in §5.3. A role missing those passes step 2 and fails here.
  4. Check the response's Warnings field. Empty is what you want. If it is populated, the document was produced but may be wrong — see the Warnings reference.

Allow for the cold start on the first call.


7. When it does not work

Symptom Cause
401 from the rendering service, no other detail The audience is configured as api://<client-id> instead of the bare GUID — §3.3
Plugin fails retrieving an environment variable One of the five in §5.4 is missing, or the caller lacks read access to environment variable values
0x80040205 when storing a document Notes are not enabled on the target table — §5.5
First deployment fails resolving the Key Vault secret Expected on first run. Grant Key Vault Secrets User to the container app identity and re-deploy — §4
azure/login fails in ~1 second, "Not all values are present" The three Azure secrets are unset, or the GitHub environment holds empty copies — §3.5
Workflow jobs fail in seconds with no runner and no logs Not a configuration problem: no runner was allocated. On a private repository this usually means exhausted Actions minutes or a spending limit
Solution import hits a publishing race Create the schema via the metadata API rather than importing hand-authored source — see the deployment runbook
Template returns 404 but exists It is deactivated. The API serves active rows only
First request very slow, subsequent ones fast Scale-to-zero cold start, by design — §4

8. After setup

  • Usage & Customization — generating documents, the API contract, and authoring or changing templates
  • Cost & Cost-Efficiency — what this costs at volume and which levers matter
  • The security & compliance review — data residency, identity model, what is stored, and an open-items register with mitigations. Written to be handed to your security team; request a copy.