Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

💙 ServiceNow Scripted REST API Terraform Module

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.

Terraform servicenow module type resources


🧩 Overview

  • 🌐 Creates the keystone scripted REST API (servicenow_scripted_rest_api.thissys_ws_definition) — the inbound web-service definition that exposes custom endpoints from the instance.
  • 🧬 Renders any number of API resources / operations (servicenow_scripted_rest_resourcesys_ws_operation) from a typed for_each map, 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 by terraform-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.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading
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

✅ Provider / Versions

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.


🔑 Required ServiceNow Roles

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.


📋 ServiceNow Prerequisites

  • 🎯 Target application scope decided — pass its sys_scope sys_id as var.scope (typically module.application.sys_id from terraform-servicenow-application). Avoid landing the API in Global.
  • 🔐 Gating roles exist — the ACL/role names referenced by enforce_acl should already be created (via terraform-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.

📁 Module Structure

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

⚙️ Quick Start

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

🔌 Cross-Module Contract

Consumes

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)

Emits

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

📚 Example Library

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."
}

⚠️ Changing service_id changes 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 }

📥 Inputs

ℹ️ Summary below.

Core identity

  • name (string, required) — display name of the API (sys_ws_definition); not the URI segment.
  • service_id (string, default null) — API identifier used in URI paths (/api/<namespace>/<service_id>); effectively immutable once consumers depend on it.

Optional API configuration

  • short_description (string, default null) — appears in the API documentation.
  • active (bool, default true) — activates the API; inactive APIs cannot serve requests.
  • doc_link (string, default null) — URL to static documentation.
  • consumes / produces (string, default null) — default request/response formats; resources may override.
  • enforce_acl (string, default null) — comma-separated ACL record names enforced API-wide.
  • protection_policy (string, default null) — "" | read | protected (validated).

Scope & lifecycle

  • scope (string, default null) — sys_scope sys_id the API and resources belong to. Leave null only when deliberately targeting the caller's current scope.
  • timeouts (object { create, update }, default null) — 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.


🧾 Outputs

  • sys_idprimary. sys_id of the scripted REST API (sys_ws_definition); the cross-resource reference and the value terraform import needs. 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.


🧠 Architecture Notes

  • sys_id is a 32-char hex identifier and is instance-specific. The same logical API has a different sys_id in dev / test / prod. Never hard-code a sys_id across instances; use the emitted output to wire siblings, and terraform import to adopt an existing record on a given instance.
  • API before resources, automatically. Each servicenow_scripted_rest_resource sets web_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_id is 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 namespace output 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 = null inherits var.scope; set it per-resource only for genuine mixed-scope cases.
  • Resource map keys are identity. Changing a key in var.resources destroys and recreates that operation (and its sys_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.

🧱 Design Principles

  • Secure-by-default authentication. Each resource defaults requires_authentication = true and requires_acl_authorization = true. Opt-out: set either to false per resource — an explicit, reviewable public/unauthorized endpoint (see Example 11).
  • Secure-by-default authorization gate. enforce_acl (API-wide) and per-resource enforce_acl reference roles from terraform-servicenow-role. Opt-out: leave them null only for a deliberately public API, and pair with disabled per-resource gates.
  • Scoped, not Global. scope targets an explicit scoped application. Opt-out: leave scope = null to use the caller's current scope — document the choice.
  • requires_snc_internal_role off by default. Defaults false; 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.

🚀 Runbook

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>

🧪 Testing

  • terraform init -backend=false && terraform validate — schema and type checks (no credentials needed).
  • terraform fmt -check — formatting gate.
  • terraform plan against a sub-production instance with SERVICENOW_* set — confirms the API and resources resolve and the integration user has web_service_admin.
  • Post-apply smoke test: call each resource_operation_uris entry and confirm the secure default returns 401 without credentials before testing an authorized call.
  • CI runs plan-only; a human reviews and applies.

💬 Example Output

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}"
}

🔍 Troubleshooting

  • 401 Unauthorized on plan/apply — the SERVICENOW_* credential chain is missing or wrong. Confirm SERVICENOW_INSTANCE_URL/_USERNAME/_PASSWORD are exported for the run and point at the intended (sub-prod) instance. The module never carries credentials.
  • 403 Forbidden creating the API or a resource — the integration user lacks web_service_admin. Grant the narrowest role (via a group) and retry.
  • Records land in Global / wrong scopevar.scope (or a resource's scope) was null or pointed at the wrong application. Set scope = module.application.sys_id; remember the computed namespace/base_uri change with scope.
  • http_method must be one of … validation error — methods are upper-case and closed to GET/POST/PUT/PATCH/DELETE. Fix the casing/value in the offending resources entry.
  • Endpoint unexpectedly returns 401/403 to a legitimate caller — that is the secure default working. Confirm enforce_acl names a role the caller holds, and that requires_authentication/requires_acl_authorization are intended.
  • Endpoint unexpectedly publicrequires_authentication/requires_acl_authorization were set false and/or enforce_acl is unset. Re-enable the gates unless the endpoint is intentionally public.
  • Resource recreated on every plan — a var.resources key changed, or service_id changed (rewriting the base path). Keep map keys and service_id stable.
  • terraform import mismatch — the sys_id is from a different instance. sys_id is instance-specific; import using the value from the target instance.

🔗 Related Docs

  • Terraform Registry — tylerhatton/servicenow provider: 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."

About

Terraform module: terraform-servicenow-scripted-rest-api

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages