Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions obp-api/src/main/protobuf/signal.proto
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ message SignalMessage {
google.protobuf.Timestamp timestamp = 6;
string message_type = 7;
string payload_json = 8; // JSON-encoded payload
int64 sequence = 9; // per-channel monotonic; poll with FetchRequest.after_sequence
}

// Mirrors SignalChannelInfoJsonV600
Expand All @@ -24,7 +25,7 @@ message SignalChannelInfo {
int64 ttl_seconds = 3;
}

// --- Publish: 1:1 with POST /signal/channels/{name}/messages ---
// --- Publish: 1:1 with POST /signal-channels/{name}/messages ---

message PublishRequest {
string channel_name = 1;
Expand All @@ -38,26 +39,33 @@ message PublishResponse {
string channel_name = 2;
google.protobuf.Timestamp timestamp = 3;
int64 channel_message_count = 4;
int64 sequence = 5;
}

// --- Fetch: 1:1 with GET /signal/channels/{name}/messages ---
// --- Fetch: 1:1 with GET /signal-channels/{name}/messages ---
// Privacy filter applied server-side: caller sees broadcasts plus messages
// to/from themselves. Same logic as REST.

message FetchRequest {
string channel_name = 1;
int32 offset = 2;
int32 limit = 3;
// > 0: cursor mode, return messages with sequence > after_sequence and ignore offset.
// Prefer this for polling: offset paging drifts once the channel is trimmed.
int64 after_sequence = 4;
}

message FetchResponse {
string channel_name = 1;
repeated SignalMessage messages = 2;
int64 total_count = 3;
bool has_more = 4;
int64 latest_sequence = 5; // newest message in the channel, 0 when empty
int64 next_after_sequence = 6; // pass back as after_sequence to continue (advances past hidden private messages too)
int64 visible_count = 7; // messages in the channel the caller may see; total_count includes private ones hidden from them
}

// --- ListChannels: 1:1 with GET /signal/channels ---
// --- ListChannels: 1:1 with GET /signal-channels ---
// Returns broadcast-visible channels only, matching REST behaviour.

message ListChannelsRequest {}
Expand Down
75 changes: 74 additions & 1 deletion obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,53 @@ featured_apis=elasticSearchWarehouseV300
# rate_limiting_per_day = -1
# rate_limiting_per_week = -1
# rate_limiting_per_month = -1
#
# -- Three rate limiters --
# OBP has three rate limiters. Each answers 429 with its own error code so a client knows which one it hit:
# 1. Self-service (self_service.rate_limit.*) runs first, before routing and authentication, keyed by
# client IP, on the endpoints anyone can call before the bank has granted them anything -> OBP-10060
# 2. Authentication (auth.rate_limit.*) runs inside the credential check of DirectLogin, DAuth,
# GatewayLogin and SIWE, keyed by IP and by account, against brute force and lockout -> OBP-10061
# 3. Consumer quota (rate_limiting_per_* and the rate limit rows written by the management endpoints and
# API Product Subscriptions) runs after authentication, keyed by consumer_id -> OBP-10018
#
# -- Authentication rate limiting (per IP and per account, before the password is checked) --
# Off by default. When enabled, shadow mode logs trips (event=auth_rate_limit_shadow_trip) and allows the
# attempt; enforce mode answers 429 OBP-10061. Counters live in Redis and fail open.
# auth.rate_limit.enabled = false
# auth.rate_limit.mode = shadow
# auth.rate_limit.per_ip.per_minute = 10
# auth.rate_limit.per_ip.per_hour = 100
# auth.rate_limit.per_user.per_minute = 6
#
# -- Self-service rate limiting (per client IP, before any credential) --
# Applies to the endpoints a caller can use before it holds credentials, grouped in scopes.
# Logins are not a scope here; the authentication limiter above counts them.
# signup POST /users, /users/email-validation, /banks/BANK_ID/user-invitations
# password_reset POST /users/password-reset-url, /users/password
# consent_request POST /consumer/consent-requests, /consumer/vrp-consent-requests
# consumer_registration POST /dynamic-registration/consumers
# lookup POST /account/check/scheme/iban
# signal_channel_create POST /signal-channels/CHANNEL_NAME/messages when the channel does not exist yet
# Enabled by default in shadow mode: a trip is logged (event=self_service_rate_limit_shadow_trip)
# and reported to the caller in the X-Rate-Limit-Warning header (OBP-10059), but the request is
# allowed. Set mode to enforce to answer 429 OBP-10060 instead. Counters live in Redis and fail open.
# self_service.rate_limit.enabled = true
# self_service.rate_limit.mode = shadow
# Generic per-IP limits; -1 switches a window off, 0 blocks every call in it.
# Built-in per-scope defaults (applied when neither a scope prop nor a generic prop is set):
# signup 3/5/10 | password_reset 3/5/10 | consent_request 10/30/100
# consumer_registration 5/10/20 | lookup 20/60/200 | signal_channel_create 5/20/50
# self_service.rate_limit.per_ip.per_minute = 10
# self_service.rate_limit.per_ip.per_hour = 60
# self_service.rate_limit.per_ip.per_day = 200
# Per-scope overrides, e.g.:
# self_service.rate_limit.signup.per_ip.per_hour = 5
# Global per-hour cap per scope across all IPs (circuit breaker against a distributed spray).
# Built-in defaults: 500 for signup, password_reset and consumer_registration; off (-1) elsewhere.
# self_service.rate_limit.signup.global.per_hour = 500
# Optional text appended to the warning when you have announced an enforcement date.
# self_service.rate_limit.enforce_announced_from = 2026-10-01
# -----------------------------------------------------

# -- Migration Scripts ----------------------------
Expand Down Expand Up @@ -1356,7 +1403,7 @@ database_messages_scheduler_interval=3600
# chat.email_digest_active_grace_minutes = 10

# Signal channels -----------------------------------------------------------
# Redis-backed ephemeral channels (/signal/channels endpoints) for lightweight
# Redis-backed ephemeral channels (/signal-channels endpoints) for lightweight
# agent-to-agent coordination. Per-channel TTL (refreshed on every publish)
# and per-channel message cap:
# messaging.channel.ttl.seconds = 3600
Expand Down Expand Up @@ -1736,6 +1783,32 @@ dynamic_code_compile_validate_dependencies=[\
PractiseEndpoint.getClass.getTypeName + "*" -> "*"\
]

# --- Dynamic code requires approval (maker/checker, see MAKER_CHECKER_DYNAMIC_CODE_DESIGN.md) ---
# dynamic_code_requires_approval=true enforces two things for the target types listed below:
# 1. Writes are queued, not applied. Create/update/delete via the v4.0.0/v6.0.0 endpoints still check the
# caller's role, validate and compile the body, then store a DynamicChangeRequest and answer 202 Accepted.
# A DIFFERENT user holding CanApproveDynamicChangeRequest applies it by quoting the payload's SHA-256 at
# /obp/v7.0.0/management/dynamic-change-requests/ID/approval.
# 2. The runtime executes only approved code. A row is served/compiled only when it is active and its body
# hash equals the hash a checker approved. Rows edited or inserted directly in the database are not run.
# The first boot with this true seeds the approved hash of every pre-existing row from its current body,
# once per database (logged in MigrationScriptLog as seedDynamicCodeApprovedHashes). After that, the only way
# a row becomes executable is a checker's approval (or an ACTIVATE change request for a row that has none).
# Approval is system level: dynamic code runs in the shared JVM, so a bank-level artefact is still approved by
# a system-level checker. Deactivation is a direct action by a single approver (four eyes to enable, one pair
# to disable) and works whether or not this prop is set.
# Defaults to false: sandboxes and local development keep today's behaviour, nothing is queued or gated.
dynamic_code_requires_approval=false
# Which target types the above applies to. Phase 1 supports the four code families.
dynamic_code_approval_target_types=DYNAMIC_RESOURCE_DOC,DYNAMIC_MESSAGE_DOC,CONNECTOR_METHOD,ABAC_RULE
# Deleting does not expand capability but does break consumers; set false to let makers delete directly.
dynamic_code_delete_requires_approval=true
# INITIATED requests older than this are marked EXPIRED when next read. 0 disables expiry.
dynamic_code_approval_request_ttl_hours=168
# Connector methods and dynamic message docs are looked up per call, so their active/approved check is
# memoised for this long. An approval or deactivation takes up to this long to reach those two families.
dynamic_code_approval_guard_cache_ttl_seconds=10

###################################################
## "Optional" / "Placeholder" JSON field behaviour
# Sometimes our connectors or data imports might populate fields with default, null or placeholder values such as empty strings, default dates and empty lists
Expand Down
2 changes: 2 additions & 0 deletions obp-api/src/main/resources/props/test.default.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ hikari.maximumPoolSize=20
# ConnectorMethodTest, AbacRuleTests, DynamicResourceDocTest, DynamicMessageDocTest and
# DynamicCodeKillSwitchTest's ON scenarios can compile/execute dynamic code locally.
allow_user_generated_scala_code=true
# Maker/checker for dynamic code is off by default; DynamicChangeRequestTest turns it on per scenario.
dynamic_code_requires_approval=false

# Permissions granted to runtime-compiled dynamic-endpoint code inside the security sandbox.
# Mirrors default.props / production.default.props. Required so dynamic resource-doc bodies can do
Expand Down
5 changes: 5 additions & 0 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ class Boot extends MdcLoggable {
// Please note that migration scripts are executed after Lift Mapper Schemifier
Migration.database.executeScripts(startedBeforeSchemifier = false)

// Maker/checker for dynamic code: when first enabled, pre-existing code rows get their current
// body hash recorded as approved so enabling the feature does not silently disable them.
code.dynamicchangerequest.MakerChecker.seedApprovedHashesIfEnabled()

// Idempotent seed of country-qualified routing schemes (TZ.MSISDN, bill, utility, etc.).
// Toggle off via routing_schemes.seed_defaults_at_boot=false in environments that don't want defaults.
code.routingscheme.RoutingSchemeSeed.runIfEnabled()
Expand Down Expand Up @@ -1081,6 +1085,7 @@ object ToSchemify extends MdcLoggable {
BulkPayment,
BulkBatchReference,
AccountAccessRequest,
code.dynamicchangerequest.DynamicChangeRequest,
code.chat.ChatRoom,
code.chat.Participant,
code.chat.ChatMessage,
Expand Down
4 changes: 4 additions & 0 deletions obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ object AbacRuleEngine {
* @return Box containing the compiled function or error
*/
def compileRule(ruleId: String, ruleCode: String): Box[AbacRuleFunction] = {
// Maker/checker execution guard: on a managed instance a rule whose current code hash differs
// from the checker-approved hash is never compiled or run (see MakerChecker.isApprovedAbacRule).
if (!code.dynamicchangerequest.MakerChecker.isApprovedAbacRule(ruleId))
return Failure(ErrorMessages.DynamicArtefactNotApproved)
compiledRulesCache.get(ruleId) match {
case Some(cachedFunction) => cachedFunction
case None =>
Expand Down
3 changes: 3 additions & 0 deletions obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class AbacRule extends AbacRuleTrait with LongKeyedMapper[AbacRule] with IdPK wi
object Policy extends MappedText(this)
object CreatedByUserId extends MappedString(this, 255)
object UpdatedByUserId extends MappedString(this, 255)
// Maker/checker: SHA-256 of the RuleCode that a checker approved. When maker/checker is enabled
// for ABAC_RULE the engine refuses to compile a rule whose current code hash differs from this.
object ApprovedHash extends MappedString(this, 64)

override def abacRuleId: String = AbacRuleId.get
override def ruleName: String = RuleName.get
Expand Down
8 changes: 4 additions & 4 deletions obp-api/src/main/scala/code/api/GatewayLogin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@ object GatewayLogin extends MdcLoggable {
logger.debug("login_user_name: " + username)
// Pre-credential rate limit. Disabled by default; controlled via auth.rate_limit.* props.
// In shadow mode trips are logged and Right is returned; only enforce mode produces Left.
AuthRateLimiter.check(APIUtil.getRemoteIpAddress(), gateway, username) match {
case Left(_) => return Failure(ErrorMessages.TooManyRequests)
AuthRateLimiter.check(callContext.map(_.ipAddress).filter(_.nonEmpty).getOrElse(APIUtil.getRemoteIpAddress()), gateway, username) match {
case Left(_) => return Failure(ErrorMessages.TooManyRequestsAuth)
case Right(_) => // continue
}
val cbsAndCallContextBox = refreshBankAccounts(jwtPayload, callContext)
Expand Down Expand Up @@ -314,8 +314,8 @@ object GatewayLogin extends MdcLoggable {
val consentId = if (jti.isEmpty) None else Some(jti)
logger.debug("login_user_name: " + username)
// Pre-credential rate limit. Disabled by default; controlled via auth.rate_limit.* props.
AuthRateLimiter.check(APIUtil.getRemoteIpAddress(), gateway, username) match {
case Left(_) => return Future.successful(Failure(ErrorMessages.TooManyRequests))
AuthRateLimiter.check(callContext.map(_.ipAddress).filter(_.nonEmpty).getOrElse(APIUtil.getRemoteIpAddress()), gateway, username) match {
case Left(_) => return Future.successful(Failure(ErrorMessages.TooManyRequestsAuth))
case Right(_) => // continue
}
val cbsAndCallContextF = refreshBankAccountsFuture(jwtPayload, callContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6463,6 +6463,7 @@ object SwaggerDefinitionsJSON {

lazy val signalMessageJsonV600 = SignalMessageJsonV600(
message_id = "d8839721-2e41-4c60-9bba-42c5a7164027",
sequence = 1771583400123456L,
channel_name = "discovery",
sender_consumer_id = "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh",
sender_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1",
Expand All @@ -6476,14 +6477,18 @@ object SwaggerDefinitionsJSON {
channel_name = "discovery",
messages = List(signalMessageJsonV600),
total_count = 1,
has_more = false
has_more = false,
latest_sequence = 1771583400123456L,
next_after_sequence = 1771583400123456L,
visible_count = 1
)

lazy val signalMessagePublishedJsonV600 = SignalMessagePublishedJsonV600(
message_id = "d8839721-2e41-4c60-9bba-42c5a7164027",
channel_name = "discovery",
timestamp = "2026-02-20T10:30:00Z",
channel_message_count = 1
channel_message_count = 1,
sequence = 1771583400123456L
)

lazy val signalChannelInfoJsonV600 = SignalChannelInfoJsonV600(
Expand Down
Loading
Loading