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
audclaim is the bare client-id GUID, notapi://<client-id>. The rendering service must therefore be configured with the bare GUID as its expected audience. Configureapi://<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:
- Build and push the image first. The container app cannot start without an image in the registry, so image build must precede infrastructure deployment.
- Deploy the infrastructure.
- 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 Userbeforehand. 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
prvAppendNoteandprvAppendTo<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 exerciseAttach=true, so a role missing them passes the health check and then fails the first time someone stores a document. TestAttach=trueexplicitly.
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
- Service health: the rendering service exposes an unauthenticated health endpoint that should
return
{"status":"ok"}. - End-to-end: invoke
sss_GenerateDocumentwith a template code and a real record id. A successful base64 PDF begins withJVBER(that is%PDFencoded) — the supplied health-check tool asserts exactly this. - Generate with
Attach=trueas a separate test. The health check does not passAttach, so it never exercises note creation — the one path that depends on theAppend/AppendToprivilege pair discussed in §5.3. A role missing those passes step 2 and fails here. - Check the response's
Warningsfield. 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.