SMS Firewall Service — API Contracts
Version: 1.0 Status: Draft Owner: Trust & Safety Last Updated: 2026-04-21 Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · SECURITY_MODEL · SYNC_CONTRACT
The sms-firewall-service exposes three interface planes:
- gRPC data-plane on
:50061(mTLS required, SPIFFE) — invoked bysmpp-connector-{mno}-{rx|trx}andsmpp-connector-transit-rx. Hot path. P95 ≤ 30 ms (MO) / 50 ms (TRANSIT). - gRPC control-plane on
:50062(mTLS required) — invoked byrouting-engine(egress DND check),channel-router-service(verdict lookup), and other in-mesh services. - HTTPS REST admin on
:3061— fronted by Kong (JWT + RBAC). For NOC console, T&S admin, and regulator auditor flows.
Both gRPC surfaces share proto/firewall/v1/firewall.proto. All REST responses use the canonical envelope.
1. gRPC service — SmsFirewallService.v1 (data-plane)
syntax = "proto3";
package ghasi.sms.firewall.v1;
option go_package = "github.com/ghasi/sms-gateway/firewall/v1";
service SmsFirewallService {
// Hot path. mTLS required. Caller SVID matched against
// spiffe://ghasi/np-data/smpp-connector-*. P95 ≤ 30 ms.
rpc FilterInbound(FilterInboundRequest) returns (Verdict);
// Hot path. mTLS required. Caller SVID matched against
// spiffe://ghasi/np-data/smpp-connector-transit-*. P95 ≤ 50 ms.
rpc EvaluateTransit(EvaluateTransitRequest) returns (Verdict);
// Idempotent verdict lookup (cdr-mediation, fraud-intel offline replay).
// Returns NOT_FOUND if outside cache TTL.
rpc GetVerdict(GetVerdictRequest) returns (Verdict);
}
message FilterInboundRequest {
string trace_id = 1;
string src_msisdn = 2; // E.164
string dst_msisdn = 3; // E.164
string mno_bind_id = 4; // e.g. "awcc-rx-01"
bytes pdu_body = 5; // raw SMPP TLV payload bytes
int32 pdu_coding = 6; // SMPP data_coding: 0 (GSM7) | 3 (latin1) | 8 (UCS2)
int32 pdu_ton = 7; // 0..6 per SMPP §5.2.5
int32 pdu_npi = 8; // 0..18 per SMPP §5.2.6
google.protobuf.Timestamp recv_ts = 9;
uint32 smpp_sequence_number = 10;
string sender_id = 11; // optional alphanumeric originator
}
message EvaluateTransitRequest {
string trace_id = 1;
uint32 peer_asn = 2;
string peer_system_id = 3;
string src_addr = 4;
string dst_msisdn = 5;
string sender_id = 6;
bytes pdu_body = 7;
int32 pdu_ton = 8;
int32 pdu_npi = 9;
bool registered_delivery = 10;
int32 esm_class = 11;
}
message GetVerdictRequest {
string verdict_id = 1;
}
message Verdict {
string verdict_id = 1; // UUIDv4, prefix "fv_"
string trace_id = 2;
FirewallAction verdict = 3;
FirewallDirection direction = 4;
BlockReason block_reason = 5; // unspecified for ALLOW/FLAG
string hold_id = 6; // populated for QUARANTINE
repeated RuleHit rule_hits = 7;
repeated string evaluated_rule_ids = 8;
int64 evaluation_latency_ms = 9;
uint32 effective_ttl_seconds = 10;
repeated string flags = 11; // MAINTENANCE_MODE, RATE_GOVERNOR_DEGRADED, ...
google.protobuf.Timestamp evaluated_at = 12;
}
enum FirewallAction {
FIREWALL_ACTION_UNSPECIFIED = 0;
ALLOW = 1;
FLAG = 2;
BLOCK = 3;
QUARANTINE = 4;
RATE_LIMIT = 5;
}
enum FirewallDirection {
FIREWALL_DIRECTION_UNSPECIFIED = 0;
MO = 1;
TRANSIT_MT = 2;
EGRESS_DND_CHECK = 3;
}
enum BlockReason {
BLOCK_REASON_UNSPECIFIED = 0;
ORIGIN_BLOCKLIST = 1;
CONTENT_FORBIDDEN = 2;
RATE_EXCEEDED = 3;
GEO_FORBIDDEN = 4;
DND_PRESENT = 5;
AIT_SIGNATURE = 6;
SIMBOX_SIGNATURE = 7;
REGULATOR_BLOCK = 8;
PEER_ASN_UNKNOWN = 9;
SENDER_ID_SPOOFED = 10;
SENDER_ID_SUSPENDED = 11;
GREY_ROUTE = 12;
PEER_QUARANTINED = 13;
}
message RuleHit {
string rule_id = 1;
string rule_name = 2;
string rule_type = 3;
FirewallAction action = 4;
string severity = 5;
string evidence = 6; // redacted; matched span replaced with ***
float confidence = 7;
}
gRPC error mapping
| gRPC status | Condition | Caller behaviour |
|---|---|---|
OK | Verdict returned | Act per verdict |
INVALID_ARGUMENT | Malformed E.164 / unknown enum | Connector logs, drops PDU, alerts |
FAILED_PRECONDITION | Unknown mno_bind_id/peer_asn | Self-deregister + alert |
PERMISSION_DENIED | SVID not allowlisted | Crash + PagerDuty |
RESOURCE_EXHAUSTED | Per-pod concurrency cap (200/bind) | Backoff and retry on sibling replica |
UNAVAILABLE / DEADLINE_EXCEEDED (> 100ms) | Pod down | Fail-closed: MO connector → local-disk WAL; transit connector → ESME_RSUBMITFAIL to peer |
INTERNAL | Handler exception | Same as UNAVAILABLE |
Idempotency & per-bind concurrency
FilterInboundis keyed by(mno_bind_id, smpp_sequence_number)for in-flight dedup; same SMPP retry within 30 s returns the cached verdict.- Per-pod concurrency cap 200 in-flight per
mno_bind_id(configurable; prevents one runaway bind from starving others).
2. gRPC service — FirewallControlPlane.v1
service FirewallControlPlane {
// Called by routing-engine before egress to MNO; checks national DND
// for tenant-originated MT only. Read-only verdict.
rpc CheckOutboundEgress(CheckOutboundEgressRequest)
returns (Verdict);
// Returns the rule-set version + checksum currently loaded.
// Used by ops tooling for cluster-wide consistency checks.
rpc GetActiveRuleSetVersion(google.protobuf.Empty)
returns (RuleSetVersionResponse);
// smpp-connector pods POST a heartbeat every 30 s to confirm liveness.
rpc RegisterBindHeartbeat(BindHeartbeatRequest)
returns (google.protobuf.Empty);
}
message CheckOutboundEgressRequest {
string trace_id = 1;
string route_id = 2;
string dst_msisdn = 3;
string sender_id = 4;
string tenant_id = 5;
}
message RuleSetVersionResponse {
uint64 version = 1;
string sha256 = 2;
uint32 active_rules = 3;
google.protobuf.Timestamp loaded_at = 4;
}
message BindHeartbeatRequest {
string mno_bind_id = 1;
string mno_id = 2;
string pod_name = 3;
google.protobuf.Timestamp ts = 4;
}
3. REST — public surface
Base path: /v1/admin/firewall (admin) and /v1/internal/firewall (mesh-internal). All endpoints fronted by Kong (jwt plugin + rate-limiting-advanced). All responses JSON. State-changing endpoints support Idempotency-Key header.
3.1 Rules CRUD (FW-US-016)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/firewall/rules | tns-admin, tns-reader, regulator-auditor | Paginated list with filters scope, enabled, type |
| GET | /v1/admin/firewall/rules/{ruleId} | same | Current rule |
| GET | /v1/admin/firewall/rules/{ruleId}/versions | same | Version history |
| POST | /v1/admin/firewall/rules | tns-admin | Create. Body: {name, scope, type, expression, action, blockReasonCode, severity, priority, enabled} → 201 {ruleId, version: 1} |
| PUT | /v1/admin/firewall/rules/{ruleId} | tns-admin | Update; new immutable version row created; version bumped |
| POST | /v1/admin/firewall/rules/{ruleId}/enable | tns-admin | Idempotent enable |
| POST | /v1/admin/firewall/rules/{ruleId}/disable | tns-admin | Idempotent disable |
| POST | /v1/admin/firewall/rules/{ruleId}/test | tns-admin | Dry-run against supplied MoContext / TransitMtContext; does NOT write audit |
| DELETE | /v1/admin/firewall/rules/{ruleId} | tns-admin | Soft-delete |
Create body example:
{
"name": "Block OTP harvest from non-AF source",
"scope": "MO",
"type": "CONTENT_REGEX",
"expression": "pdu.body.matches('(?i)\\b(verify|otp)\\b') && src.country != 'AF'",
"action": "BLOCK",
"blockReasonCode": "CONTENT_FORBIDDEN",
"severity": "HIGH",
"priority": 100,
"enabled": true
}
3.2 Blocklist CRUD (FW-US-012, FW-US-014, FW-US-015)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/firewall/blocklists | tns-admin, regulator-auditor | List blocklists |
| GET | /v1/admin/firewall/blocklists/{id}/entries | same | Paginated entries (cursor-based) |
| POST | /v1/admin/firewall/blocklists/{id}/entries | tns-admin | Add entry; source defaults to OPERATOR_MANUAL |
| POST | /v1/admin/firewall/blocklists/{id}/entries:bulk | tns-admin | Atomic batch ≤ 10 000 entries; rejects partial |
| DELETE | /v1/admin/firewall/blocklists/{id}/entries/{entryId} | tns-admin | Soft-delete |
| GET | /v1/admin/firewall/blocklist/{entryId}/history | tns-admin, regulator-auditor | Chronological events: created, source_added, source_removed, confidence_changed, manually_overridden, deactivated, reactivated |
| POST | /v1/admin/firewall/federation/sync | tns-admin | Manually trigger federation export (out-of-band) |
3.3 Quarantine (FW-US-011)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/firewall/quarantine?status=PENDING&page=1&pageSize=50 | noc, tns-admin | Paginated list |
| GET | /v1/admin/firewall/quarantine/{holdId} | noc, tns-admin | Full MoContext/TransitMtContext; PDU body decrypted on the fly (audit logged); body redacted in the response logger |
| POST | /v1/admin/firewall/quarantine/{holdId}/release | noc | {notes} → re-evaluate against current rules; on ALLOW → re-inject via firewall.quarantine.released.v1 |
| POST | /v1/admin/firewall/quarantine/{holdId}/reject | noc | {reason} → terminal REJECTED |
3.4 MNO Bind Registry (FW-US-017)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/firewall/mno-binds | carrier-relations, tns-admin | List (no secrets) |
| POST | /v1/admin/firewall/mno-binds | carrier-relations | Body: {mnoBindId, mnoId, direction, permittedCountryCodes[], permittedSenderIds[], notes} → 201 |
| PATCH | /v1/admin/firewall/mno-binds/{id} | carrier-relations | Update permitted lists |
| DELETE | /v1/admin/firewall/mno-binds/{id} | carrier-relations | Soft delete + firewall.mno_bind.deactivated.v1 |
| POST | /v1/internal/firewall/mno-binds/{id}/heartbeat | smpp-connector (mTLS SVID) | Connector heartbeat; missing > 60 s emits firewall.alert.bind.missing.v1 |
3.5 Operating Mode (FW-US-019)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/firewall/mode | any authenticated | Current mode |
| POST | /v1/admin/firewall/mode | noc, tns-admin | {targetMode, reason, secondApproverToken?} — dual-approval (60 s window) |
| GET | /v1/admin/firewall/mode/history | tns-admin, regulator-auditor | Audit chain of mode changes |
3.6 Audit & Statistics
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/firewall/audit | tns-admin, regulator-auditor | Filter by direction, verdict, srcMsisdn, dstMsisdn, mnoBindId, peerAsn, from, to, cursor pagination; results MSISDN-masked for regulator-auditor |
| GET | /v1/internal/firewall/blocklist/export?since={iso8601} | regulator-portal-service (mTLS SVID) | JSON Lines + HSM signature for regulator submission |
| GET | /v1/admin/firewall/stats/public | any authenticated | Aggregate, non-PII stats: verdict distribution, blocklist size, federation lag |
3.7 Internal / operational
| Method | Path | Caller | Purpose |
|---|---|---|---|
| GET | /health/live, /health/ready | Kubernetes | Liveness / readiness |
| GET | /metrics | Prometheus | Scrape (port 9061) |
4. REST error envelope
{
"error": {
"code": "FIREWALL_RULE_INVALID_INPUT_REF",
"message": "Rule expression references undefined input 'pdu.foo'",
"traceId": "00-abc...",
"details": { "field": "expression", "ref": "pdu.foo" }
}
}
| HTTP | Code | When |
|---|---|---|
| 400 | FIREWALL_RULE_INVALID_INPUT_REF | Rule expression references undefined input |
| 400 | FIREWALL_VALIDATION_FAILED | Payload invalid |
| 401 | UNAUTHENTICATED | Missing/invalid JWT |
| 403 | INSUFFICIENT_SCOPE | Caller role lacks required scope |
| 404 | NOT_FOUND | Rule / blocklist entry / quarantine hold not found |
| 409 | CONFLICT | Hold already reviewed; rule version mismatch; mode-switch already in progress |
| 412 | DUAL_APPROVAL_REQUIRED | Mode switch / quarantine release without second approver |
| 422 | RULE_UNSAFE_EXPRESSION | CEL expression contains forbidden function (os.system, file IO, network) |
| 422 | RULE_REGEX_REDOS_RISK | Regex failed ReDoS screen (≤ 50 ms safe-eval test) |
| 422 | BLOCKLIST_BULK_PARTIAL_FAIL | Bulk insert had per-entry validation failures (rolled back) |
| 422 | FEDERATION_SIGNATURE_INVALID | Regulator HSM signature did not validate |
| 429 | RATE_LIMITED | Kong rate-limit |
| 500 | INTERNAL | Unhandled internal error |
| 503 | DEPENDENCY_UNAVAILABLE | Postgres / Redis / NATS unavailable; fail-closed in effect |
Cross-reference: docs/standards/ERROR_CODES.md.
5. Pagination & filtering conventions
- Cursor pagination for time-series endpoints (
audit,quarantine,blocklist/{id}/entries): query?cursor={opaque}&limit={1..100}; response containsnextCursorandtotal. - Page-based for small admin lists (
rules,mno-binds):?page=1&pageSize=50(maxpageSize=200). - Sort:
?sort=field:asc|desc, defaultevaluatedAt:descfor time-series. - Time filters: ISO 8601 with timezone (e.g.
?from=2026-04-21T00:00:00+04:30&to=2026-04-21T23:59:59+04:30).
6. Rate limits & quotas (Kong)
| Surface | Limit |
|---|---|
Admin REST (per user, per role tns-admin) | 600 req/min |
Admin REST (per user, role noc) | 1200 req/min |
| Admin bulk endpoints (blocklist bulk, federation sync) | 10 req/min per user |
Audit query (regulator-auditor) | 60 req/min |
| Statistics public | 600 req/min per token |
gRPC FilterInbound / EvaluateTransit | No Kong limit (mTLS only); per-pod concurrency 1000, per-bind 200 |
7. Versioning
- gRPC:
ghasi.sms.firewall.v1. Breaking change →v2package in parallel with ≥ 90-day deprecation. - REST:
/v1/admin/firewall/*. Additive changes are non-breaking. schemaVersionfield on events per EVENT_SCHEMAS §7.- OpenAPI 3.1 served at
GET /v1/admin/firewall/openapi.json. .protofiles versioned in this repo; generated TypeScript + Go clients published to internal package registry on every deploy.- Pact contract tests verify connector ↔ firewall gRPC contract and admin-dashboard ↔ firewall REST contract on every CI run.