SailPoint ISC Greenfield Implementation Field Guide
Cloud Rule and Connector Rule Templates and Snippets
On this page
Rules are where most of the real integration logic lived on the program: 13 cloud rules and 41 connector rules. This page is the starting kit: which rule type to use, how to deliver one, and a template for each shape we used. The snippets are generalised from the program's rules with names, hosts and credentials removed. They show structure. Compile every one in the validator and test it on a sandbox source before use. Class and method names should be checked against the Javadoc for your tenant release.
Two kinds of rule
| Cloud rule | Connector rule | |
|---|---|---|
| Runs | Inside ISC | On the virtual appliance, around a connector operation |
| Network access | None | The connector's own managed client only |
| Editable | By SailPoint, after review | Directly, through the connector-rule API |
| Typical use | Compute a value, correlate, adjust a plan | Parse a response, build a request, talk to a database |
| Review | Submitted to SailPoint, with the validator output. Most are reviewed within about a day | None |
| Lives in | Source, identity profile or provisioning policy configuration | A source operation or a source attribute |
The review difference drives workflow. A cloud-rule change is a request with a lead time. A connector-rule change is an API call that takes effect on the next run, which makes it fast and dangerous.
Cloud rule types
| Type | Purpose | Typical input | Returns |
|---|---|---|---|
| Account profile attribute generator | Compute an account attribute when provisioning (a login name, an email) | The identity, the application, and the helper object | A value |
| Identity attribute rule | Compute an identity attribute for the identity profile | The identity | A value |
| Correlation rule | Match an account to an identity with logic a simple criterion cannot express | The account | A map naming the identity attribute and the value to match |
| Before provisioning rule | Adjust the provisioning plan before it reaches the source | The plan, the identity, the application | Nothing (it edits the plan) |
| Manager correlation rule | Work out an identity's manager | The account or identity | A map |
| BuildMap rule | Reshape rows from a file | The row data | A map |
| Transform rule | Used by a transform | The transform input | A value |
Variables seen in the program's rules: identity and application for generators and attribute rules, account for correlation, plan, application and identity for before-provisioning, and in all of them log and the helper idn.
Connector rule types
| Type | Connector | Purpose | Variables seen |
|---|---|---|---|
| Before operation | Web Services | Run before an HTTP operation: set the body, add a header, fetch a value | application, requestEndPoint, oldResponseMap, restClient, plus provisioningPlan on write operations. Returns the end point |
| After operation | Web Services | Run after an HTTP operation: parse, reshape, set the disabled flag | application, requestEndPoint, processedResponseObject, rawResponseObject, restClient. Returns the list of accounts or groups |
| BuildMap | JDBC | Turn each result row into an account map | result, schema. Returns the map |
| JDBC provision | JDBC | Perform every write | plan, connection, application. Returns a provisioning result |
| BuildMap | Delimited file | Reshape parsed file rows | the row data |
| Before and after create, modify, delete | Active Directory | Run a script on the directory agent | (scripts run on the Windows host) |
SailPoint's documentation states that connector rules run on the appliance, receive managed connections from the platform, and that additional connections or call-outs are not allowed. On the program, web-services rules made calls through the connector's own client helper and that worked. Raw HTTP libraries did not (see the connectors pages). Treat any call a rule makes as a dependency and keep it to the connector's helper.
Delivering rules safely
Cloud rules
- One rule per use case, updated in place. Dozens of near-identical copies accumulated: four lifecycle-state variants and two next-processing-date variants sat in the tenant, with only one referenced. Keep the name plain and archive history in version control.
- Controlled change window. SailPoint's guidance is to submit a
-TEMPcopy, validate it in the window, then update the original and ask for the temporary one to be deleted. - Submission standard. UTF-8, readable indentation, comments on non-obvious logic, no commented-out code, tested. Run the validator before you submit and attach its output.
- Plan lead time. Allow about a day, and more for a rule that is complex or poorly commented.
Connector rules
The connector-rule API supports create, update, delete and a validate call. Attach a rule to a source with a JSON patch:
PATCH /v3/sources/{id} Content-Type: application/json-patch+json| What you attach | Path |
|---|---|
| Before provisioning rule | /beforeProvisioningRule |
| Native rules (directory scripts) | /connectorAttributes/nativeRules (a list: set all of them together) |
| Account correlation rule | /accountCorrelationRule |
| Manager correlation rule | /managerCorrelationRule |
| BuildMap or JDBC BuildMap | /connectorAttributes/buildMapRule |
| JDBC provision | /connectorAttributes/jdbcProvisionRule |
| Web Services operation before or after | /connectorAttributes/connectionParameters/<index>/beforeRule and /afterRule |
[
{ "op": "replace", "path": "/connectorAttributes/jdbcProvisionRule", "value": "<rule name>" }
]Paths and endpoint versions change, so confirm them in the current API reference. The rule's script field is a JSON string, so newlines and quotes must be escaped. Generate it with a script, not by hand.
House rules for every rule
- Header comment with purpose, owner, version, date and the sources it is attached to.
- No credentials, no URLs with secrets. Read them from the source.
- Idempotent. Running twice must not do harm.
- Log with
log, not a webhook, and never log a secret or a whole account map in production. - Fail loudly on missing input, quietly on "nothing to do".
- Set the disabled flag in both directions in any rule that builds accounts.
- Never rename an entitlement format a rule parses.
- List every rule in the documentation pack, with the sources it serves. A rule shared by two sources changes both.
Cloud rule templates
Template: unique login name (account profile attribute generator)›
Used for a directory login name built from name parts plus a counter, with a uniqueness check and a hard limit so it cannot loop forever. The helper checks the name against accounts already on the source.
import java.util.Random;
// Purpose : generate a unique login name (<=8 chars) from first and last name.
// Owner : <team> Version: 1.0 Attached to: <directory source> create policy
int MAX_ATTEMPTS = 2000;
int counter = 1;
int attempts = 0;
String CHARS = "abcdefghijklmnopqrstuvwxyz";
public String clean(String s) {
if (s == null || s.trim().isEmpty()) s = "def";
s = s.replaceAll("[^a-zA-Z]", "").toLowerCase();
// pad short names so the pattern below always has three characters
Random r = new Random();
StringBuilder b = new StringBuilder(s);
while (b.length() < 3) b.append(CHARS.charAt(r.nextInt(CHARS.length())));
return b.toString();
}
public String build(String fn, String ln, int n) {
String first = fn.substring(0, Math.min(3, fn.length()));
String last = ln.substring(Math.max(0, ln.length() - 3));
String num = (n < 10) ? "0" + n : String.valueOf(n);
String out = first + last + num;
// trim to the limit, shortening the name part and keeping the counter
if (out.length() > 8) out = (first + last).substring(0, 8 - num.length()) + num;
return out;
}
public boolean unique(String candidate) {
return !idn.accountExistsByDisplayName(application.getName(), candidate);
}
String fn = clean(identity.getAttribute("firstname"));
String ln = clean(identity.getAttribute("lastname"));
String candidate;
do {
candidate = build(fn, ln, counter++);
attempts++;
} while (!unique(candidate) && attempts < MAX_ATTEMPTS);
if (attempts >= MAX_ATTEMPTS) {
throw new RuntimeException("Could not generate a unique login name after " + MAX_ATTEMPTS + " attempts");
}
log.debug("Generated login name " + candidate);
return candidate;Notes: decide what a name with accents, hyphens or one letter should produce, and test those. Keep the check against live data, because two people hired the same day will collide. Document the pattern, because the service desk will be asked how it works.
Template: date calculated in the person's own time zone (identity attribute rule)›
Used for lifecycle and "next processing" attributes. The key points are to read the person's time zone, to fall back safely when it is missing or invalid, and to treat the date as a point in time.
import java.time.*;
import java.time.format.DateTimeFormatter;
// Purpose : compute the instant a person becomes inactive, at 22:00 local on the end date.
String DEFAULT_ZONE = "<IANA zone, for example Europe/London>";
String endDate = identity.getAttribute("endDate"); // yyyy-MM-dd or ISO instant
String tz = identity.getAttribute("timezone");
if (endDate == null || endDate.trim().isEmpty()) return null; // no end date: no value
ZoneId zone;
try {
zone = ZoneId.of(tz);
} catch (Exception e) { // null or invalid zone
zone = ZoneId.of(DEFAULT_ZONE);
}
LocalDate day;
try {
day = endDate.contains("T")
? ZonedDateTime.parse(endDate, DateTimeFormatter.ISO_ZONED_DATE_TIME).toLocalDate()
: LocalDate.parse(endDate, DateTimeFormatter.ISO_LOCAL_DATE);
} catch (Exception e) {
log.warn("Unparseable end date: " + endDate);
return null;
}
ZonedDateTime cutoff = day.atTime(22, 0).atZone(zone);
return cutoff.toInstant().toString(); // store as UTCNotes: compare dates as instants, never as local strings. Test the daylight-saving change days, a missing zone, and an end date in the past.
Template: correlation rule›
Used when a simple "attribute equals attribute" criterion is not enough, such as a privileged account with a prefix that must be stripped to find its owner.
Map out = new HashMap();
String login = account.getStringAttribute("sAMAccountName");
String value;
if (login == null || login.trim().isEmpty()) {
value = "NO_MATCH_BLANK"; // a value that cannot match, rather than null
} else if (login.indexOf('_') == 1 && login.indexOf('_') == login.lastIndexOf('_')) {
value = login.substring(2); // "a_jsmith" -> "jsmith"
} else {
value = login;
}
out.put("identityAttributeName", "<identity attribute>");
out.put("identityAttributeValue", value);
return out;Notes: return a value that cannot match, not null, for a blank input. Remember matching is exact, so normalise case on both sides.
Template: before-provisioning rule skeleton›
Used to change the plan before it reaches the source: rewrite an operation, add an argument, or drop an entitlement. Keep each change small and table-driven.
import sailpoint.object.ProvisioningPlan;
import sailpoint.object.ProvisioningPlan.AccountRequest;
import sailpoint.object.ProvisioningPlan.AttributeRequest;
String P = "BeforeProv <name> - ";
if (plan == null || plan.getAccountRequests() == null) return;
for (AccountRequest ar : plan.getAccountRequests()) {
ProvisioningPlan.ObjectOperation op = ar.getOp();
// Example: turn a disable into a modify that moves the account
if (ProvisioningPlan.ObjectOperation.Disable.equals(op)) {
log.debug(P + "rewriting Disable for " + ar.getNativeIdentity());
ar.setOp(ProvisioningPlan.ObjectOperation.Modify);
ar.add(new AttributeRequest("<attribute>", ProvisioningPlan.Operation.Set, "<value>"));
continue;
}
// Example: never touch a protected account
String ni = ar.getNativeIdentity();
if (ni != null && ni.startsWith("<protected prefix>")) {
throw new RuntimeException(P + "refusing to change protected account " + ni);
}
}Notes: a before rule changes what the connector receives, so test every operation (create, modify, enable, disable, delete) after any edit. A rule that turns disable into modify must also set the attributes that do the real work.
Template: turn add and remove into set (replace-only connectors)›
For a connector that cannot add or remove one value of a multi-valued attribute and cannot read a single account, the rule reads the current values from ISC, applies the change, and sends the whole list.
import java.util.*;
import sailpoint.object.ProvisioningPlan;
import sailpoint.object.Schema;
private List asList(Object o) {
if (o instanceof Collection) return new ArrayList((Collection) o);
return new ArrayList(Collections.singletonList(o));
}
String P = "AddRemoveToSet - ";
if (plan == null || plan.getAccountRequests() == null) return;
Schema schema = application.getSchema("account");
if (schema == null) { log.warn(P + "no account schema"); return; }
for (ProvisioningPlan.AccountRequest ar : plan.getAccountRequests()) {
if (ProvisioningPlan.ObjectOperation.Create.equals(ar.getOp())) continue;
if (ProvisioningPlan.ObjectOperation.Delete.equals(ar.getOp())) continue;
if (ar.getAttributeRequests() == null) continue;
for (ProvisioningPlan.AttributeRequest a : ar.getAttributeRequests()) {
if (a.getName() == null || a.getValue() == null || a.getOp() == null) continue;
boolean add = ProvisioningPlan.Operation.Add.equals(a.getOp());
boolean rem = ProvisioningPlan.Operation.Remove.equals(a.getOp());
if (!add && !rem) continue;
if (!schema.isMultiValued(a.getName())) continue;
String ni = ar.getNativeIdentity() != null ? ar.getNativeIdentity() : plan.getNativeIdentity();
String app = ar.getApplicationName() != null ? ar.getApplicationName() : application.getName();
if (ni == null) { log.error(P + "no native identity"); continue; }
Object acct = idn.getAccountByNativeIdentity(app, ni);
if (acct == null) { log.error(P + "account not found " + ni); continue; }
Object current = idn.getRawAccountAttribute(acct, a.getName());
if (current == null) continue;
List values = asList(current);
List change = asList(a.getValue());
if (add) values.addAll(change); else values.removeAll(change);
a.setValue(values);
a.setOp(ProvisioningPlan.Operation.Set);
}
}Notes: the read comes from ISC's last aggregation, so a value changed in the target since then is overwritten. State that limit in the integration notes, and prefer a live read in a connector rule when the target allows one.
Connector rule templates
Template: web services before operation (build a body, no credentials)›
Used on a write operation to build the request body from the plan, optionally enriched by a live lookup. Credentials come from the source, not the rule.
import java.util.*;
import connector.common.JsonUtil;
import sailpoint.object.ProvisioningPlan.AccountRequest;
import sailpoint.object.ProvisioningPlan.AttributeRequest;
import sailpoint.connector.webservices.WebServicesClient;
import sailpoint.tools.Util;
String P = "<Source> BeforeAddEnt - ";
// 1. Read what the plan carries
String nativeId = null;
String value = null;
for (AccountRequest ar : (List<AccountRequest>) provisioningPlan.getAccountRequests()) {
nativeId = ar.getNativeIdentity();
AttributeRequest req = ar.getAttributeRequest("<entitlement attribute>");
if (req != null && req.getValue() != null) value = req.getValue().toString();
}
if (Util.isNullOrEmpty(nativeId) || Util.isNullOrEmpty(value)) {
throw new RuntimeException(P + "native identity or entitlement value missing");
}
// 2. Optional live lookup through the connector's client (needs a token the source already holds)
// WebServicesClient client = new WebServicesClient();
// Map args = new HashMap(); args.put("url", lookupUrl); client.configure(args);
// String json = client.executeGet(lookupUrl, headers, Arrays.asList("2**"));
// if (Util.isNullOrEmpty(json)) throw new RuntimeException(P + "lookup returned nothing");
// 3. Build the body. Escape values; prefer a map and JSON serialiser to string concatenation.
Map payload = new LinkedHashMap();
payload.put("userId", nativeId);
payload.put("entitlementId", value);
// payload.put("startDate", startDate);
Map body = new HashMap();
body.put("jsonBody", JsonUtil.toJson(payload)); // confirm the serialiser name for your release
requestEndPoint.setBody(body);
log.info(P + "built body for " + nativeId);
return requestEndPoint;Notes: never build JSON by concatenating strings around user values, because a quote in a name breaks the request. Check the lookup result and fail with a clear message. Do not paste a client id or secret into the rule: one early rule did, and it is a security finding.
Template: web services before operation for revoke (read live, then decide)›
Used when removing access needs the target's current state: remove one role, or delete the row if it would be empty, or disable the user if nothing remains. Reading live data makes revoke correct even when the aggregated view is stale.
String P = "<Source> BeforeRemove - ";
// 1. Parse the identifiers packed into the entitlement value: "<name> ((<rowId>::<roleId>))"
String ent = <entitlement value from the attribute request>;
int a = ent.lastIndexOf("((");
int b = ent.lastIndexOf("))");
if (a < 0 || b < a) throw new RuntimeException(P + "entitlement value has no identifier segment: " + ent);
String[] ids = ent.substring(a + 2, b).split("::");
if (ids.length != 2) throw new RuntimeException(P + "identifier segment malformed: " + ent);
String rowId = ids[0];
String roleId = ids[1];
// 2. Read the live grants for this user
// String live = client.executePost(getUserUrl, headers, body, Arrays.asList("2**"));
// 3. Decide
// - the row keeps other roles -> call "remove role from row"
// - removing would empty the row -> call "delete row"
// - the user would have no access -> call "disable user"
// 4. Return the end point configured for the chosen call.
return requestEndPoint;Notes: if the format of the value changes, this rule and the aggregation rule that produces it must change in the same release. Throw when the identifier is missing, so a failed parse never reports success.
Template: web services after operation (aggregation, disabled flag in both directions)›
Used to turn the raw response into accounts, set the disabled flag and keep only the fields you want.
import java.util.*;
import connector.common.JsonUtil;
import sailpoint.tools.GeneralException;
String P = "<Source> AfterAccountAgg - ";
try {
List<Map> users = JsonUtil.parseJson(rawResponseObject);
List out = new ArrayList();
for (Map u : users) {
Object status = u.get("<status field>");
// Compare tolerantly and set the flag in both directions.
boolean inactive = status != null && "inactive".equalsIgnoreCase(status.toString().trim());
u.put("IIQDisabled", inactive);
// Allowlist: keep only the fields the schema defines.
Map clean = new HashMap();
for (String k : new String[] { "<id>", "<name>", "<email>", "<group list>" }) {
if (u.containsKey(k)) clean.put(k, u.get(k));
}
clean.put("IIQDisabled", inactive);
out.add(clean);
}
log.info(P + "accounts returned: " + out.size()); // a population monitor in the log
Map result = new HashMap();
result.put("data", out);
return result;
} catch (Exception e) {
throw new GeneralException(P + "error processing response: " + e.getMessage(), e);
}Notes: a single case-sensitive test such as "InActive".equals(status) silently makes every account look enabled when the application changes its wording. An allowlist stops a vendor adding a field that quietly changes what ISC stores. Log the count so a truncated feed is visible.
Template: web services after operation for groups (a dummy entitlement)›
For an application with no groups, give every account one entitlement to review and revoke.
Map role = new HashMap();
role.put("Id", "<stable id>");
role.put("Name", "<Application> Access");
List data = new ArrayList();
data.add(role);
Map out = new HashMap();
out.put("data", data);
return out;Notes: pair it with a before-remove rule that turns "remove the dummy entitlement" into the application's offboarding call, and document that revoking it does that.
Template: JDBC build map›
Used to turn a database row into an account map and set the disabled flag from a status column.
import sailpoint.connector.*;
import java.util.Map;
Map map = JDBCConnector.buildMapFromResultSet(result, schema);
if (map != null) {
Object st = map.get("<status column>");
// both directions, tolerant comparison, and a deliberate default for unknown values
boolean disabled = (st == null) ? false : !"active".equalsIgnoreCase(st.toString().trim());
map.put("IIQDisabled", disabled);
}
return map;Notes: decide what an unknown or null status means and write it down. Do not log the whole map on every row in production.
Template: JDBC provisioning rule skeleton›
Used to perform every write on a database source. The structure that worked: one helper per statement, one branch per operation, a result object that records success or failure, and a transaction around multi-statement operations.
import java.sql.*;
import java.util.*;
import sailpoint.object.ProvisioningPlan;
import sailpoint.object.ProvisioningPlan.AccountRequest;
import sailpoint.object.ProvisioningPlan.AttributeRequest;
import sailpoint.object.ProvisioningResult;
import connector.common.JsonUtil;
String P = "<Source> JDBCProvision - ";
public Object attr(AccountRequest ar, String name) {
AttributeRequest r = (ar == null) ? null : ar.getAttributeRequest(name);
return (r == null) ? null : r.getValue();
}
// Read and consume the metadata that the request workflow put in the comments
public Map readMeta(AccountRequest ar, String attrName) {
AttributeRequest r = ar.getAttributeRequest(attrName);
if (r == null || r.getComments() == null) throw new RuntimeException(P + "request carries no metadata");
Map m = JsonUtil.toMap(r.getComments());
r.setComments(null);
if (m == null) throw new RuntimeException(P + "METADATA IS NULL");
return m;
}
public boolean exists(Connection c, String user, String role, String entity) throws SQLException {
try (PreparedStatement s = c.prepareStatement(
"SELECT COUNT(*) FROM <assignment table> WHERE UserName = ? AND RoleName = ? AND Entity = ?")) {
s.setString(1, user); s.setString(2, role); s.setString(3, entity);
try (ResultSet rs = s.executeQuery()) { rs.next(); return rs.getInt(1) > 0; }
}
}
ProvisioningResult result = new ProvisioningResult();
if (plan != null && plan.getAccountRequests() != null) {
for (AccountRequest ar : (List<AccountRequest>) plan.getAccountRequests()) {
try {
String user = ar.getNativeIdentity();
if (user == null || user.trim().isEmpty() || "none".equals(user)) {
throw new RuntimeException(P + "no native identity");
}
if (AccountRequest.Operation.Create.equals(ar.getOperation())) {
Map meta = readMeta(ar, "<entitlement attribute>");
connection.setAutoCommit(false);
try {
// INSERT account ... ; INSERT assignment ... (parameterised statements only)
connection.commit();
result.setStatus(ProvisioningResult.STATUS_COMMITTED);
} catch (SQLException e) {
connection.rollback();
throw e;
} finally {
connection.setAutoCommit(true);
}
} else if (AccountRequest.Operation.Modify.equals(ar.getOperation())) {
// for each AttributeRequest: Add -> insert (check exists() first), Remove -> delete
result.setStatus(ProvisioningResult.STATUS_COMMITTED);
} else if (AccountRequest.Operation.Disable.equals(ar.getOperation())) {
// UPDATE account SET status = <disabled> WHERE user = ?
result.setStatus(ProvisioningResult.STATUS_COMMITTED);
} else if (AccountRequest.Operation.Enable.equals(ar.getOperation())) {
// UPDATE account SET status = <enabled> WHERE user = ?
result.setStatus(ProvisioningResult.STATUS_COMMITTED);
} else {
throw new RuntimeException(P + "unsupported operation " + ar.getOperation());
}
} catch (Exception e) {
log.error(P + "failed: " + e.getMessage());
result.setStatus(ProvisioningResult.STATUS_FAILED);
result.addError(e);
}
}
}
return result;Notes:
- Parameterised statements only. Names come from forms and HR data.
- A transaction around multi-statement creates, with a rollback. Several helpers in the program's rules marked a failure without rolling back, so a sequence of modifies could succeed in part.
- One status per account request. In a loop, make sure a later success does not overwrite an earlier failure. Keep a flag.
- Unknown operations should fail, not fall through silently.
- Do not catch and ignore. A swallowed exception sent a request with blank fields once.
Template: delimited-file customisation rule (cloud)›
The default rule for a cloud flat-file source is a single call. Keep it unless you need to reshape rows.
import sailpoint.thunderbolt.service.ConnectorService;
return ConnectorService.applyStandardFlatFileCustomizations(context, application, connector, object);Snippet: a safe debug helper (use only if you must)›
Logging through log is the right choice. If a diagnostic webhook already exists, this is the safe shape: it is gated by a flag, has short timeouts, and can never fail the operation.
boolean DEBUG_WEBHOOK = false; // keep false in production
public void debugSend(String message) {
if (!DEBUG_WEBHOOK) return;
try {
java.net.HttpURLConnection c = (java.net.HttpURLConnection) new java.net.URL("<listener url>").openConnection();
c.setConnectTimeout(2000);
c.setReadTimeout(2000);
c.setRequestMethod("POST");
c.setDoOutput(true);
c.getOutputStream().write(message.getBytes("UTF-8"));
c.getResponseCode();
} catch (Exception e) {
log.warn("debug webhook failed: " + e.getMessage()); // swallow
}
}The unsafe version threw on failure and set no timeouts, which made provisioning fail or hang whenever the listener was down. Four production rules carried it.
Testing rules
| Step | How |
|---|---|
| Compile | The rule validator, before every submission or update |
| Unit logic | Copy the pure functions (parsing, name building) into a scratch class and run them against a table of inputs, including empty, null, very long and accented values |
| One object | Run the operation for a single test account before a batch |
| Each operation | Create, modify, enable, disable, delete after any edit to a provisioning rule |
| Negative | A missing comment, a malformed entitlement value, a null status, an API error body returned with HTTP 200 |
| Evidence | Keep the log lines and a before and after of the target for the documentation pack |
| Rollback | Keep the previous version of the script so a bad change is one API call to undo |
Common errors
| Symptom | Likely cause |
|---|---|
| Compile error on a type | A missing import, or a class the sandbox does not allow |
Rule: <name> not found | The rule name in the source does not match |
| Rule runs but nothing changes | The attach path or the operation index is wrong |
METADATA IS NULL | The request carried no comment (see the complex applications page) |
| A literal placeholder lands in the target | A before rule did not run on that operation |
| Every account enabled after a vendor update | A status comparison stopped matching |
| Hang or consecutive aggregation errors after a change | A raw HTTP library inside the rule |
Actions
- Keep every rule in version control with its attach path, and export from the tenant on a schedule to catch drift.
- Start from a template here, strip what you do not need, and add the header comment.
- Review each new rule for credentials, debug helpers and disabled-flag direction before it is attached.
- Budget a day of lead time for every cloud-rule change.
- Test negative cases as carefully as the happy path.