Manages an inbound scripted REST API (sys_ws_definition) and its resources/operations (sys_ws_operation) as one secure-by-default composite — every endpoint is born requiring authentication and ACL authorization. Built for the tylerhatton/servicenow provider v0.11.x.
- 🌐 Creates the keystone scripted REST API (
servicenow_scripted_rest_api.this→sys_ws_definition) — the inbound web-service definition that exposes custom endpoints from the instance. - 🧬 Renders any number of API resources / operations (
servicenow_scripted_rest_resource→sys_ws_operation) from a typedfor_eachmap, each declaring an HTTP method, a relative path, and a server-side operation script. - 🔒 Secure-by-default: every resource requires authentication and ACL authorization unless you explicitly opt out; the API-wide ACL gate (
enforce_acl) references roles produced byterraform-servicenow-role. - 🏷️ Scoped, not Global: the API and its resources land in an explicit application scope (
sys_scope), with per-resource override. - 🔗 Wires each resource to its parent API by
sys_id, so Terraform always creates the API before any operation.
💡 Why it matters: an inbound REST endpoint is live attack surface on a system of record. This module makes the safe posture the default — authenticated, ACL-gated, scoped — and forces every relaxation to be an explicit, reviewable line of HCL.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
app["terraform-servicenow-application<br/>(scoped application)"]
role["terraform-servicenow-role<br/>(roles + ACLs)"]
api["terraform-servicenow-scripted-rest-api<br/>(inbound web service)"]
consumers["External callers /<br/>integrations / imports"]
app -->|scope sys_id| api
role -->|enforce_acl| api
api -->|sys_id, service_id, base_uri| consumers
style api fill:#81B5A1,color:#fff
This module consumes a scope sys_id from terraform-servicenow-application and ACL/role names from terraform-servicenow-role, and emits the API sys_id, service_id, and base_uri that external callers, integrations, and terraform import rely on.
flowchart TD
subgraph mod["terraform-servicenow-scripted-rest-api"]
this["servicenow_scripted_rest_api.this<br/>sys_ws_definition (keystone)"]
res["servicenow_scripted_rest_resource.resource<br/>sys_ws_operation — for_each var.resources"]
this -->|web_service_definition = this.id| res
end
scope["var.scope (sys_scope)"] -.->|scope| this
scope -.->|scope fallback| res
acl["var.enforce_acl (ACL names)"] -.->|enforce_acl| this
style this fill:#81B5A1,color:#fff
| Resource | Backing table | Cardinality | Role |
|---|---|---|---|
servicenow_scripted_rest_api.this |
sys_ws_definition |
exactly one | keystone — the API definition |
servicenow_scripted_rest_resource.resource |
sys_ws_operation |
for_each over var.resources (0..N) |
child operations exposed by the API |
| Requirement | Version |
|---|---|
| Terraform | >= 1.5.0 |
tylerhatton/servicenow |
= 0.11.0 (pinned exactly — provider is pre-1.0) |
This module declares only the provider requirement (providers.tf) — it configures no provider "servicenow" {} block. The root/caller supplies the instance URL and basic-auth credentials from a secret store (see Runbook). A module that configures its own provider cannot be composed.
| Role | Required for | Notes |
|---|---|---|
web_service_admin |
Create/manage scripted REST APIs (sys_ws_definition) and their resources (sys_ws_operation) |
Narrowest role for inbound web-service definitions — prefer it over admin. |
ℹ️ The integration user is a dedicated, least-privilege service account authenticating via basic auth — never a personal or all-powerful admin login.
- 🎯 Target application scope decided — pass its
sys_scopesys_id asvar.scope(typicallymodule.application.sys_idfromterraform-servicenow-application). Avoid landing the API in Global. - 🔐 Gating roles exist — the ACL/role names referenced by
enforce_aclshould already be created (viaterraform-servicenow-role); default to requiring an explicit role. - 🧰 No store-app activation is required for core scripted REST — the platform's web-service framework is built in.
terraform-servicenow-scripted-rest-api/
├── providers.tf # required_providers (servicenow = 0.11.0); no provider {} block
├── variables.tf # typed inputs: name, API config, scope, resources map
├── main.tf # keystone API + for_each resources
├── outputs.tf # sys_id (primary) + name/service_id/base_uri/namespace + child maps
├── README.md # this file
└── SCOPE.md # composite scope, roles, prerequisites, gotchas
module "scripted_rest_api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Loan Servicing API"
service_id = "loan_servicing"
short_description = "Inbound endpoints for the loan-servicing integration."
# Land in a scoped application — never Global.
scope = module.application.sys_id
# API-wide ACL gate — reference roles produced by terraform-servicenow-role.
enforce_acl = module.role.name
resources = {
get_loan = {
name = "Get Loan"
http_method = "GET"
relative_path = "/loans/{id}"
operation_script = <<-JS
(function process(request, response) {
var id = request.pathParams.id;
//... look up and return the loan...
})(request, response);
JS
short_description = "Fetch a single loan by id."
}
}
}The caller configures the provider from the environment / secret store — this module never sees credentials:
provider "servicenow" {} # reads SERVICENOW_INSTANCE_URL / _USERNAME / _PASSWORD
| Input | Type | Source module |
|---|---|---|
scope |
string | terraform-servicenow-application — the scope the API belongs to |
enforce_acl (ACL/role name) |
string | terraform-servicenow-role — role required to call the endpoint (least-privilege) |
| Output | Description | Consumed by |
|---|---|---|
sys_id |
sys_id of the scripted REST API (instance-specific) | Imports, references |
name / service_id |
API name and base-path identifier | Consumers calling the endpoint |
base_uri / namespace |
Computed base path and namespace | Documentation, client wiring |
resource_sys_ids |
Map of resource key → sys_id | Audit, imports |
resource_operation_uris |
Map of resource key → resolved operation URI | Client wiring, smoke tests |
1 · Minimal — API with no resources yet
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Telemetry API"
}Stages a definition you can attach resources to later. active defaults to true.
2 · Set an explicit service_id (stable base path)
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Member Portal API"
service_id = "member_portal" # /api/<namespace>/member_portal
short_description = "Endpoints backing the member self-service portal."
}
⚠️ Changingservice_idchanges the public base path callers depend on — treat it as effectively immutable once consumers exist.
3 · Stage a definition before exposing it (active = false)
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Draft Reporting API"
active = false # inactive APIs cannot serve requests
}4 · Land in a scoped application
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Scoped Integration API"
scope = module.application.sys_id # secure-by-default: explicit scope, not Global
}5 · API-wide ACL gate via enforce_acl
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Restricted API"
enforce_acl = "x_casey_loan_read,x_casey_loan_write" # comma-separated ACL record names
}Per-resource enforcement (requires_authentication, requires_acl_authorization) defaults true on top of this — leave them on unless the API is intentionally public.
6 · Default content negotiation (consumes / produces)
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "JSON-only API"
consumes = "application/json"
produces = "application/json"
}Resources inherit these unless they set their own consumes / produces.
7 · Documentation link + protection policy
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Documented API"
doc_link = "https://wiki.casey.internal/apis/documented"
protection_policy = "read" # "" | read | protected
}8 · A single GET resource
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Lookup API"
resources = {
get_status = {
name = "Get Status"
http_method = "GET"
relative_path = "/status"
operation_script = <<-JS
(function process(request, response) {
response.setBody({ ok: true });
})(request, response);
JS
}
}
}9 · Full CRUD — multiple resources keyed by stable strings
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Widget API"
service_id = "widget"
scope = module.application.sys_id
resources = {
list_widgets = { name = "List Widgets", http_method = "GET", relative_path = "/widgets", operation_script = file("${path.module}/scripts/list.js") }
get_widget = { name = "Get Widget", http_method = "GET", relative_path = "/widgets/{id}", operation_script = file("${path.module}/scripts/get.js") }
create_widget = { name = "Create Widget", http_method = "POST", relative_path = "/widgets", operation_script = file("${path.module}/scripts/create.js") }
update_widget = { name = "Update Widget", http_method = "PATCH", relative_path = "/widgets/{id}", operation_script = file("${path.module}/scripts/update.js") }
delete_widget = { name = "Delete Widget", http_method = "DELETE", relative_path = "/widgets/{id}", operation_script = file("${path.module}/scripts/delete.js") }
}
}Keys (list_widgets, …) are the stable map identifiers — they key the resource_sys_ids / resource_operation_uris outputs and must not change casually.
10 · Security / least-privilege variant (per-resource gates explicit)
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Member PII API"
scope = module.application.sys_id
enforce_acl = module.role.name
resources = {
get_member = {
name = "Get Member"
http_method = "GET"
relative_path = "/members/{id}"
operation_script = file("${path.module}/scripts/get_member.js")
# Hardened on purpose — these are the secure defaults, stated for the reviewer.
requires_authentication = true
requires_acl_authorization = true
enforce_acl = "x_casey_member_read"
}
}
}11 · Explicit public endpoint (reviewable opt-out)
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Public Health Check"
resources = {
healthz = {
name = "Health Check"
http_method = "GET"
relative_path = "/healthz"
operation_script = "(function(r,s){ s.setStatus(200); })(request, response);"
# ⚠️ Explicit opt-out — unauthenticated, unauthorized endpoint. Justify in review.
requires_authentication = false
requires_acl_authorization = false
}
}
}12 · Per-resource scope override
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Mixed-scope API"
scope = module.app_primary.sys_id
resources = {
shared_op = {
name = "Shared Op"
http_method = "GET"
operation_script = file("${path.module}/scripts/shared.js")
scope = module.app_shared.sys_id # overrides the module-level scope
}
}
}When a resource omits scope it falls back to the module-level var.scope.
13 · Operation timeouts on the API record
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Slow-to-provision API"
timeouts = {
create = "5m"
update = "5m"
}
}14 · Versioned resource + request example for the docs
module "api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Versioned API"
resources = {
create_v2 = {
name = "Create (v2)"
http_method = "POST"
relative_path = "/items"
web_service_version = "v2"
operation_script = file("${path.module}/scripts/create_v2.js")
consumes = "application/json"
produces = "application/json"
request_example = jsonencode({ name = "example", amount = 100 })
}
}
}15 · End-to-end composition — application + role + scripted REST API
module "application" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-application?ref=v1.0.0"
#... scoped application definition...
}
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-role?ref=v1.0.0"
#... role + ACL protecting the endpoint...
}
module "scripted_rest_api" {
source = "git::https://github.com/microsoftexpert/terraform-servicenow-scripted-rest-api?ref=v1.0.0"
name = "Loan Servicing API"
service_id = "loan_servicing"
scope = module.application.sys_id # sibling sys_id → scope
enforce_acl = module.role.name # sibling role → API-wide ACL gate
resources = {
get_loan = {
name = "Get Loan"
http_method = "GET"
relative_path = "/loans/{id}"
operation_script = file("${path.module}/scripts/get_loan.js")
enforce_acl = module.role.name
}
}
}
output "loan_api_sys_id" { value = module.scripted_rest_api.sys_id }
output "loan_api_base" { value = module.scripted_rest_api.base_uri }
output "loan_op_uris" { value = module.scripted_rest_api.resource_operation_uris }ℹ️ Summary below.
Core identity
name(string, required) — display name of the API (sys_ws_definition); not the URI segment.service_id(string, defaultnull) — API identifier used in URI paths (/api/<namespace>/<service_id>); effectively immutable once consumers depend on it.
Optional API configuration
short_description(string, defaultnull) — appears in the API documentation.active(bool, defaulttrue) — activates the API; inactive APIs cannot serve requests.doc_link(string, defaultnull) — URL to static documentation.consumes/produces(string, defaultnull) — default request/response formats; resources may override.enforce_acl(string, defaultnull) — comma-separated ACL record names enforced API-wide.protection_policy(string, defaultnull) —""|read|protected(validated).
Scope & lifecycle
scope(string, defaultnull) —sys_scopesys_id the API and resources belong to. Leavenullonly when deliberately targeting the caller's current scope.timeouts(object{ create, update }, defaultnull) — operation timeouts for the API record.
Child collection
resources(map(object({...})), default{}) — keyed by a caller-supplied stable string. Per-object schema:
resources = {
"<key>" = {
name = string # required — resource display name
http_method = string # required — GET|POST|PUT|PATCH|DELETE (upper-case, validated)
operation_script = string # required — server-side script
relative_path = optional(string)
web_service_version = optional(string)
active = optional(bool, true)
requires_authentication = optional(bool, true) # 🔒 secure default
requires_acl_authorization = optional(bool, true) # 🔒 secure default
requires_snc_internal_role = optional(bool, false)
enforce_acl = optional(string) # overrides the API value
consumes = optional(string) # overrides the API value
produces = optional(string) # overrides the API value
protection_policy = optional(string) # "" | read | protected (validated)
request_example = optional(string)
short_description = optional(string)
scope = optional(string) # overrides the module-level scope
}
}Validations: every http_method must be one of GET/POST/PUT/PATCH/DELETE; each protection_policy must be null or "" | read | protected; each resource needs a non-empty name and operation_script.
sys_id— primary. sys_id of the scripted REST API (sys_ws_definition); the cross-resource reference and the valueterraform importneeds. Instance-specific.name— display name of the API.service_id— API identifier used in URI paths.base_uri— computed base API path (URI).namespace— computed namespace (depends on the application scope).resource_sys_ids— map of resource key → sys_id; empty{}when no resources are defined.resource_operation_uris— map of resource key → resolved operation URI (base path + version + relative path).
No outputs are marked
sensitive— this module manages public web-service metadata only and never accepts or emits secret material.
sys_idis a 32-char hex identifier and is instance-specific. The same logical API has a differentsys_idin dev / test / prod. Never hard-code asys_idacross instances; use the emitted output to wire siblings, andterraform importto adopt an existing record on a given instance.- API before resources, automatically. Each
servicenow_scripted_rest_resourcesetsweb_service_definition = servicenow_scripted_rest_api.this.id, creating an implicit dependency — Terraform always creates the keystone API before any operation and tears them down in reverse. service_idis effectively immutable in practice. It defines the public base path; changing it after consumers exist breaks their URLs. The provider may allow an update, but treat it as a breaking change.- Scope drives namespace. The computed
namespaceoutput depends on the application scope the API lives in. Moving scope changes the public path — decide scope up front. - Per-resource scope falls back to the module scope. A resource with
scope = nullinheritsvar.scope; set it per-resource only for genuine mixed-scope cases. - Resource map keys are identity. Changing a key in
var.resourcesdestroys and recreates that operation (and itssys_id). Choose stable keys. - Basic auth only. The provider authenticates with HTTP basic auth supplied by the caller; there is no per-module credential surface.
- Secure-by-default authentication. Each resource defaults
requires_authentication = trueandrequires_acl_authorization = true. Opt-out: set either tofalseper resource — an explicit, reviewable public/unauthorized endpoint (see Example 11). - Secure-by-default authorization gate.
enforce_acl(API-wide) and per-resourceenforce_aclreference roles fromterraform-servicenow-role. Opt-out: leave themnullonly for a deliberately public API, and pair with disabled per-resource gates. - Scoped, not Global.
scopetargets an explicit scoped application. Opt-out: leavescope = nullto use the caller's current scope — document the choice. requires_snc_internal_roleoff by default. Defaultsfalse; enable only when a resource genuinely needs the SNC Internal Role.- Secrets out of band. The module manages web-service metadata only — no private keys, passwords, or tokens flow through variables, state, or outputs.
- Promotion-friendly. Configuration is tracked by the platform (Update Sets / source control); nothing here disables change tracking.
terraform init -backend=false
terraform validate
terraform fmt -check
# plan / apply require SERVICENOW_* credentials for a (sub-prod) instance:
export SERVICENOW_INSTANCE_URL="https://<instance>.service-now.com"
export SERVICENOW_USERNAME="<integration_user>"
export SERVICENOW_PASSWORD="<from secret store>"
terraform plan
terraform apply
terraform output
⚠️ Always pin the module source with?ref=v1.0.0— never a branch. The provider is pre-1.0; pin it= 0.11.0.
Adopt an existing API on an instance:
terraform import servicenow_scripted_rest_api.this <sys_id>
# child resources:
terraform import 'servicenow_scripted_rest_resource.resource["get_loan"]' <resource_sys_id>terraform init -backend=false && terraform validate— schema and type checks (no credentials needed).terraform fmt -check— formatting gate.terraform planagainst a sub-production instance withSERVICENOW_*set — confirms the API and resources resolve and the integration user hasweb_service_admin.- Post-apply smoke test: call each
resource_operation_urisentry and confirm the secure default returns 401 without credentials before testing an authorized call. - CI runs plan-only; a human reviews and applies.
Outputs:
sys_id = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
name = "Loan Servicing API"
service_id = "loan_servicing"
base_uri = "/api/x_casey_loan/loan_servicing"
namespace = "x_casey_loan"
resource_sys_ids = {
"get_loan" = "f0e1d2c3b4a5968778695a4b3c2d1e0f"
}
resource_operation_uris = {
"get_loan" = "/api/x_casey_loan/loan_servicing/loans/{id}"
}
401 Unauthorizedon plan/apply — theSERVICENOW_*credential chain is missing or wrong. ConfirmSERVICENOW_INSTANCE_URL/_USERNAME/_PASSWORDare exported for the run and point at the intended (sub-prod) instance. The module never carries credentials.403 Forbiddencreating the API or a resource — the integration user lacksweb_service_admin. Grant the narrowest role (via a group) and retry.- Records land in Global / wrong scope —
var.scope(or a resource'sscope) wasnullor pointed at the wrong application. Setscope = module.application.sys_id; remember the computednamespace/base_urichange with scope. http_method must be one of …validation error — methods are upper-case and closed toGET/POST/PUT/PATCH/DELETE. Fix the casing/value in the offendingresourcesentry.- Endpoint unexpectedly returns 401/403 to a legitimate caller — that is the secure default working. Confirm
enforce_aclnames a role the caller holds, and thatrequires_authentication/requires_acl_authorizationare intended. - Endpoint unexpectedly public —
requires_authentication/requires_acl_authorizationwere setfalseand/orenforce_aclis unset. Re-enable the gates unless the endpoint is intentionally public. - Resource recreated on every plan — a
var.resourceskey changed, orservice_idchanged (rewriting the base path). Keep map keys andservice_idstable. terraform importmismatch — thesys_idis from a different instance.sys_idis instance-specific; import using the value from the target instance.
- Terraform Registry —
tylerhatton/servicenowprovider:servicenow_scripted_rest_api,servicenow_scripted_rest_resource. - ServiceNow product docs — Scripted REST APIs (
sys_ws_definition) and Scripted REST Resources (sys_ws_operation). - sibling modules —
terraform-servicenow-application(scope),terraform-servicenow-role(roles + ACLs). - This module's
SCOPE.md— composite scope, Required ServiceNow roles, prerequisites, and provider gotchas.
💙 "Infrastructure as Code should be standardized, consistent, and secure."