From e642919e5872ec6dd53d22da6bbc143f0ddba483 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 22 May 2026 09:03:34 +0200 Subject: [PATCH 1/7] Update Glossary.scala --- obp-api/src/main/scala/code/api/util/Glossary.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index ad6c29dd3e..80f26baea4 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -5672,7 +5672,7 @@ object Glossary extends MdcLoggable { |For onward calls to OBP-API, `OBP_AUTHORIZATION_VIA` selects: | |- **`oauth`** — pulls the access token from the MCP request context and sends `Authorization: Bearer ...`. - |- **`consent`** — if the endpoint declares any required roles and no `Consent-JWT` is supplied, the tool returns a `consent_required` payload listing the required roles and bank scope, so the client can elicit user approval and come back with a `Consent-JWT` header. Public / no-role endpoints skip this and call straight through. + |- **`consent`** — the default mode for user-facing deployments. `call_obp_api` requires a `Consent-JWT` for **every** endpoint except a small allowlist of genuinely public ones (`GET /root`, the bank directory `/banks` and `/banks/{BANK_ID}`, glossary, resource-docs, API metadata). For any other endpoint called without a `Consent-JWT`, the tool returns a `consent_required` payload — required roles, bank / account / view scope, and `requires_view_access` / `is_user_scoped` flags — so the client can build the right consent and retry with a `Consent-JWT` header. Consent is required **by default**, not only for role-gated endpoints, because many identity-bound endpoints (`/users/current`, `/my/*`, account-access-via-view endpoints) declare no roles yet still need the caller's identity — a role-only gate would call them unauthenticated. The allowlist is deliberately conservative: a wrongly-excluded endpoint costs only an extra prompt, whereas wrongly skipping consent fails silently. |- **`none`** — calls OBP unauthenticated (only useful for genuinely public endpoints). | |This means the consent flow is enforced at the MCP layer, not just at OBP-API: an agent cannot accidentally call a privileged endpoint without explicit user consent. From 7a4e5aa5d5a383c0ceb5ec6eabf806ef0f546112 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 23 May 2026 09:52:47 +0200 Subject: [PATCH 2/7] Create count_v7_endpoints.py --- scripts/count_v7_endpoints.py | 271 ++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 scripts/count_v7_endpoints.py diff --git a/scripts/count_v7_endpoints.py b/scripts/count_v7_endpoints.py new file mode 100644 index 0000000000..035951d6de --- /dev/null +++ b/scripts/count_v7_endpoints.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +Count the OBP endpoints reachable via /obp/v7.0.0/. + +This statically reproduces what `Http4s700.allResourceDocs` computes at runtime. + +Aggregation chain (from the OBPAPIxxx.scala / Http4sXYZ.scala source): + + OBPAPI1_4_0.allResourceDocs = APIMethods140 ++ APIMethods130 ++ APIMethods121 (plain concat) + OBPAPI2_0_0 = collectResourceDocs(OBPAPI1_4_0, Http4s200) + OBPAPI2_1_0 = collectResourceDocs(OBPAPI2_0_0, Http4s210) + OBPAPI2_2_0 = collectResourceDocs(OBPAPI2_1_0, Http4s220) + OBPAPI3_0_0 = collectResourceDocs(OBPAPI2_2_0, Http4s300) + OBPAPI3_1_0 = collectResourceDocs(OBPAPI3_0_0, Http4s310) + OBPAPI4_0_0 = collectResourceDocs(OBPAPI3_1_0, Http4s400).filterNot(v4 excludeEndpoints) + OBPAPI5_0_0 = collectResourceDocs(OBPAPI4_0_0, Http4s500) + OBPAPI5_1_0 = collectResourceDocs(OBPAPI5_0_0, Http4s510).filterNot(v5.1 excludeEndpoints) + OBPAPI6_0_0 = collectResourceDocs(OBPAPI5_1_0, Http4s600).filterNot(v6 excludeEndpoints) + Http4s700.allResourceDocs = collectResourceDocs(OBPAPI6_0_0, Http4s700) (v7 excludeEndpoints = Nil) + +collectResourceDocs: concatenate, stable-sort by API version DESCENDING, then keep +the first ResourceDoc seen for each (requestUrl, requestVerb) pair. +filterNot: drop any ResourceDoc whose partialFunctionName exactly equals an +excluded name (Scala `String.matches(names.mkString("|"))` is a whole-string match). + +Run: python3 scripts/count_v7_endpoints.py +""" + +import os +import re +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC = os.path.join(REPO, "obp-api", "src", "main", "scala", "code", "api") + +# file -> API version (as a sortable (major, minor, patch) tuple) +VERSION_FILES = [ + ("v1_2_1/APIMethods121.scala", (1, 2, 1)), + ("v1_3_0/APIMethods130.scala", (1, 3, 0)), + ("v1_4_0/APIMethods140.scala", (1, 4, 0)), + ("v2_0_0/Http4s200.scala", (2, 0, 0)), + ("v2_1_0/Http4s210.scala", (2, 1, 0)), + ("v2_2_0/Http4s220.scala", (2, 2, 0)), + ("v3_0_0/Http4s300.scala", (3, 0, 0)), + ("v3_1_0/Http4s310.scala", (3, 1, 0)), + ("v4_0_0/Http4s400.scala", (4, 0, 0)), + ("v5_0_0/Http4s500.scala", (5, 0, 0)), + ("v5_1_0/Http4s510.scala", (5, 1, 0)), + ("v6_0_0/Http4s600.scala", (6, 0, 0)), + ("v7_0_0/Http4s700.scala", (7, 0, 0)), +] + +# excludeEndpoints lists, taken verbatim from the OBPAPIxxx.scala source. +# Applied as filterNot(partialFunctionName) at the level keyed below. +EXCLUDE = { + (4, 0, 0): { + "addPermissionForUserForBankAccountForMultipleViews", + "removePermissionForUserForBankAccountForAllViews", + "addPermissionForUserForBankAccountForOneView", + "removePermissionForUserForBankAccountForOneView", + "createAccount", + "revokeConsent", + }, + (5, 1, 0): { + "getUserByUsername", "getBadLoginStatus", "unlockUser", "lockUser", + "createUserWithAccountAccess", "grantUserAccessToView", + "revokeUserAccessToView", "revokeGrantUserAccessToViews", + }, + (6, 0, 0): { + "getUserByUsername", "getBadLoginStatus", "unlockUser", "lockUser", + "createUserWithAccountAccess", "grantUserAccessToView", + "revokeUserAccessToView", "revokeGrantUserAccessToViews", + "getMyPersonalUserAttributes", "createMyPersonalUserAttribute", + "updateMyPersonalUserAttribute", "createNonPersonalUserAttribute", + "getNonPersonalUserAttributes", "deleteNonPersonalUserAttribute", + }, +} + +REG_START = re.compile(r'^\s*(?:static)?[Rr]esourceDocs\s*\+=\s*ResourceDoc\s*\(') +HTTP_VERBS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + + +class Doc: + __slots__ = ("verb", "url", "func", "version", "src") + + def __init__(self, verb, url, func, version, src): + self.verb, self.url, self.func = verb, url, func + self.version, self.src = version, src + + def key(self): + return (self.url, self.verb) + + +def split_top_level_args(text, want): + """Return the first `want` comma-separated args of a Scala call. + + `text` starts just after the opening '(' of ResourceDoc(...). Tracks string + literals (incl. triple-quoted), nested brackets, and // and /* */ comments + so commas inside them are not treated as separators. + """ + args, buf = [], [] + depth = 0 + i, n = 0, len(text) + while i < n and len(args) < want: + c = text[i] + two = text[i:i + 2] + three = text[i:i + 3] + if three == '"""': # triple-quoted string + end = text.find('"""', i + 3) + if end == -1: + buf.append(text[i:]); break + buf.append(text[i:end + 3]); i = end + 3; continue + if c == '"': # single-quoted string + j = i + 1 + while j < n: + if text[j] == '\\': + j += 2; continue + if text[j] == '"': + break + j += 1 + buf.append(text[i:j + 1]); i = j + 1; continue + if two == '//': # line comment + end = text.find('\n', i) + i = n if end == -1 else end + continue + if two == '/*': # block comment + end = text.find('*/', i + 2) + i = n if end == -1 else end + 2 + continue + if c in '([{': + depth += 1; buf.append(c); i += 1; continue + if c in ')]}': + if depth == 0: # closing ResourceDoc( + break + depth -= 1; buf.append(c); i += 1; continue + if c == ',' and depth == 0: + args.append("".join(buf)); buf = []; i += 1; continue + buf.append(c); i += 1 + if buf and len(args) < want: + args.append("".join(buf)) + return args + + +def strip_str(literal): + s = literal.strip() + if s.startswith("s"): # interpolator prefix + s = s[1:].strip() + if s.startswith('"""') and s.endswith('"""'): + return s[3:-3] + if s.startswith('"') and s.endswith('"'): + return s[1:-1] + return s + + +def parse_file(path, version): + with open(path, encoding="utf-8") as fh: + lines = fh.readlines() + docs = [] + for idx, line in enumerate(lines): + if not REG_START.match(line): + continue + # gather a generous window of source from the opening paren onward + chunk = "".join(lines[idx:idx + 60]) + chunk = chunk[chunk.index("ResourceDoc") + len("ResourceDoc"):] + chunk = chunk[chunk.index("(") + 1:] + args = split_top_level_args(chunk, want=5) + if len(args) < 5: + print(f" WARN: could not parse args at {path}:{idx + 1}", file=sys.stderr) + continue + func_arg, verb_arg, url_arg = args[2], args[3], args[4] + m = re.search(r'nameOf\s*\(\s*[\w.]*?(\w+)\s*\)', func_arg) + func = m.group(1) if m else strip_str(func_arg) + verb = strip_str(verb_arg).upper() + url = strip_str(url_arg) + if verb not in HTTP_VERBS: + print(f" WARN: unexpected verb {verb!r} at {path}:{idx + 1}", file=sys.stderr) + continue + docs.append(Doc(verb, url, func, version, f"{os.path.basename(path)}:{idx + 1}")) + return docs + + +def collect(*buckets): + """Reproduce collectResourceDocs: concat, stable sort by version desc, dedup by (url,verb).""" + merged = [d for bucket in buckets for d in bucket] + merged.sort(key=lambda d: d.version, reverse=True) # stable + seen, out = set(), [] + for d in merged: + if d.key() not in seen: + seen.add(d.key()) + out.append(d) + return out + + +def filter_not(docs, excluded_names): + return [d for d in docs if d.func not in excluded_names] + + +def main(): + by_version = {} + print("Parsing ResourceDoc registrations:") + for rel, version in VERSION_FILES: + path = os.path.join(SRC, rel) + if not os.path.isfile(path): + sys.exit(f"ERROR: missing source file {path}") + docs = parse_file(path, version) + by_version[version] = docs + print(f" {rel:30s} v{'.'.join(map(str, version))}: {len(docs):4d} docs") + print() + + # OBPAPI1_4_0.allResourceDocs = plain ++ (no dedup, no filter) + level = by_version[(1, 4, 0)] + by_version[(1, 3, 0)] + by_version[(1, 2, 1)] + + steps = [ + ((2, 0, 0), None), ((2, 1, 0), None), ((2, 2, 0), None), + ((3, 0, 0), None), ((3, 1, 0), None), + ((4, 0, 0), EXCLUDE[(4, 0, 0)]), + ((5, 0, 0), None), + ((5, 1, 0), EXCLUDE[(5, 1, 0)]), + ((6, 0, 0), EXCLUDE[(6, 0, 0)]), + ((7, 0, 0), None), + ] + excluded_log = [] + for version, excl in steps: + level = collect(level, by_version[version]) + if excl: + removed = [d for d in level if d.func in excl] + excluded_log += [(version, d) for d in removed] + level = filter_not(level, excl) + + final = level + + # transparency: did any exclusion remove a (url,verb) that another doc also serves? + final_keys = {d.key() for d in final} + shadow = [(v, d) for v, d in excluded_log if d.key() in final_keys] + + print("=" * 66) + print("OBP endpoints reachable via /obp/v7.0.0/") + print("=" * 66) + print(f"v7.0.0 native (http4s) endpoints : {len(by_version[(7,0,0)]):4d}") + print(f"Total reachable (aggregated + deduped) : {len(final):4d}") + print() + + wins = {} + for d in final: + wins[d.version] = wins.get(d.version, 0) + 1 + print("Owned by version (newest wins on URL+verb clash):") + for version in sorted(wins, reverse=True): + print(f" v{'.'.join(map(str, version)):<14s} {wins[version]:4d}") + print() + + verbs = {} + for d in final: + verbs[d.verb] = verbs.get(d.verb, 0) + 1 + print("By HTTP method:") + for verb in sorted(verbs, key=lambda v: -verbs[v]): + print(f" {verb:<8s} {verbs[verb]:4d}") + print() + + print(f"Endpoints removed by excludeEndpoints filters: {len(excluded_log)}") + for version, d in excluded_log: + print(f" v{'.'.join(map(str, version))} drops {d.verb:6s} {d.url} ({d.func})") + if shadow: + print() + print("NOTE: an excluded endpoint shared a (url,verb) still served by " + "another doc — count may need a closer look:") + for version, d in shadow: + print(f" {d.verb} {d.url} ({d.func})") + + +if __name__ == "__main__": + main() From dc9ce553a6d180dce5c94f0ec22225d417742cb2 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 23 May 2026 09:56:46 +0200 Subject: [PATCH 3/7] Tweaking CONSENT MAX TTL default and now we have a constant. --- obp-api/src/main/resources/props/sample.props.template | 3 ++- obp-api/src/main/scala/code/api/constant/constant.scala | 4 ++++ obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala | 4 ++-- obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala | 4 ++-- obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala | 6 +++--- obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala | 2 +- obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala | 2 +- 7 files changed, 15 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 25a7b10509..b0d8cdba07 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1150,7 +1150,8 @@ database_messages_scheduler_interval=3600 # Possibile values are: CONSUMER_CERTIFICATE, CONSUMER_KEY_VALUE, NONE # consumer_validation_method_for_consent=CONSUMER_CERTIFICATE # -# consents.max_time_to_live=3600 +# Maximum allowed time_to_live (in seconds) for a consent. Default is 7776000 (90 days), matching PSD2 AIS / UK Open Banking. +# consents.max_time_to_live=7776000 # In case isn't defined default value is "true" # consents.sca.enabled=true # --------------------------------------------------------- diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 9be5407d6b..db2143d8d0 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -306,6 +306,10 @@ object Constant extends MdcLoggable { def RATE_LIMIT_ACTIVE_PREFIX: String = getVersionedCachePrefix(RL_ACTIVE_NAMESPACE) final val RATE_LIMIT_ACTIVE_CACHE_TTL: Int = APIUtil.getPropsValue("rateLimitActive.cache.ttl.seconds", "3600").toInt + // Default max time_to_live for consents, in seconds. 90 days — aligns with PSD2 AIS / UK Open Banking. + // Used as the fallback when the `consents.max_time_to_live` prop is unset. + final val DEFAULT_CONSENT_TTL: Int = 7776000 + // Connector Cache Prefixes (with global namespace and versioning) def CONNECTOR_PREFIX: String = getVersionedCachePrefix(CONNECTOR_NAMESPACE) diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala index c336b41eae..17e503f2be 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -117,7 +117,7 @@ object Http4s310 { |Consumer-Key: ejznk505d132ryomnhbx1qmtohurbsbb0kijajsk |cache-control: no-cache | - |Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 3600 seconds. + |Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 7776000 seconds (90 days). | |Example of POST JSON: |{ @@ -4453,7 +4453,7 @@ object Http4s310 { StrongCustomerAuthentication.EMAIL.toString, StrongCustomerAuthentication.IMPLICIT.toString).contains(scaMethod) } - maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL) _ <- code.util.Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = Some(cc)) { consentJson.time_to_live match { case Some(ttl) => ttl <= maxTimeToLive diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 878c4d47b6..0c88bec547 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -855,7 +855,7 @@ object Http4s500 { consentJson <- NewStyle.function.tryons(failMsg, 400, callContextOpt) { net.liftweb.json.parse(rawBody).extract[PostConsentRequestJsonV500] } - maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL) _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { consentJson.time_to_live match { case Some(ttl) => ttl <= maxTimeToLive @@ -1210,7 +1210,7 @@ object Http4s500 { } else { Future.successful((BankId(""), AccountId(""), ViewId(""), CounterpartyId(""))): Future[(BankId, AccountId, ViewId, CounterpartyId)] } - maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL) _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { consentRequestJson.time_to_live match { case Some(ttl) => ttl <= maxTimeToLive diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 5807783cfb..ad3f8f6ef6 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -4676,7 +4676,7 @@ object Http4s510 { |Consumer-Key: ejznk505d132ryomnhbx1qmtohurbsbb0kijajsk |cache-control: no-cache | - |Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 3600 seconds. + |Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 7776000 seconds (90 days). | |Example of POST JSON: |{ @@ -4768,7 +4768,7 @@ object Http4s510 { consentJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostConsentBodyCommonJson ", 400, callContextOpt) { net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostConsentBodyCommonJson] } - maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL) _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { consentJson.time_to_live match { case Some(ttl) => ttl <= maxTimeToLive @@ -4940,7 +4940,7 @@ object Http4s510 { postConsentRequestJsonV510 <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostVRPConsentRequestJsonV510 ", 400, callContextOpt) { parsedBody.extract[PostVRPConsentRequestJsonV510] } - maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL) _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { postConsentRequestJsonV510.time_to_live match { case Some(ttl) => ttl <= maxTimeToLive diff --git a/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala index 952bda759e..03fd2b5f11 100644 --- a/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala +++ b/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala @@ -85,7 +85,7 @@ class ConsentTest extends V310ServerSetup { .copy(valid_from = Some(new Date())) .copy(views=views) - val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=3600) + val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=Constant.DEFAULT_CONSENT_TTL) val timeToLive: Option[Long] = Some(maxTimeToLive + 10) feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala index 24ef66169a..a2c38d523e 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala @@ -73,7 +73,7 @@ class ConsentObpTest extends V510ServerSetup { .copy(consumer_id=Some(testConsumer.consumerId.get)) .copy(views=views) - val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=3600) + val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=Constant.DEFAULT_CONSENT_TTL) val timeToLive: Option[Long] = Some(maxTimeToLive + 10) feature(s"test $CreateConsent version $VersionOfApi - Unauthorized access") From 823c703f9917b2b9064fb2e056c568ff03f02702 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 23 May 2026 10:04:28 +0200 Subject: [PATCH 4/7] fix import --- obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 0c88bec547..8a3fb98a64 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -855,7 +855,7 @@ object Http4s500 { consentJson <- NewStyle.function.tryons(failMsg, 400, callContextOpt) { net.liftweb.json.parse(rawBody).extract[PostConsentRequestJsonV500] } - maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL) + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = DEFAULT_CONSENT_TTL) _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { consentJson.time_to_live match { case Some(ttl) => ttl <= maxTimeToLive @@ -1210,7 +1210,7 @@ object Http4s500 { } else { Future.successful((BankId(""), AccountId(""), ViewId(""), CounterpartyId(""))): Future[(BankId, AccountId, ViewId, CounterpartyId)] } - maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL) + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = DEFAULT_CONSENT_TTL) _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { consentRequestJson.time_to_live match { case Some(ttl) => ttl <= maxTimeToLive From 8ce72033174a043e4d1c2b2a9cf7d599325232a1 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 23 May 2026 11:16:45 +0200 Subject: [PATCH 5/7] Update count_v7_endpoints.py --- scripts/count_v7_endpoints.py | 402 +++++++++++++++++++++------------- 1 file changed, 247 insertions(+), 155 deletions(-) diff --git a/scripts/count_v7_endpoints.py b/scripts/count_v7_endpoints.py index 035951d6de..6d361e1178 100644 --- a/scripts/count_v7_endpoints.py +++ b/scripts/count_v7_endpoints.py @@ -2,88 +2,54 @@ """ Count the OBP endpoints reachable via /obp/v7.0.0/. -This statically reproduces what `Http4s700.allResourceDocs` computes at runtime. - -Aggregation chain (from the OBPAPIxxx.scala / Http4sXYZ.scala source): - - OBPAPI1_4_0.allResourceDocs = APIMethods140 ++ APIMethods130 ++ APIMethods121 (plain concat) - OBPAPI2_0_0 = collectResourceDocs(OBPAPI1_4_0, Http4s200) - OBPAPI2_1_0 = collectResourceDocs(OBPAPI2_0_0, Http4s210) - OBPAPI2_2_0 = collectResourceDocs(OBPAPI2_1_0, Http4s220) - OBPAPI3_0_0 = collectResourceDocs(OBPAPI2_2_0, Http4s300) - OBPAPI3_1_0 = collectResourceDocs(OBPAPI3_0_0, Http4s310) - OBPAPI4_0_0 = collectResourceDocs(OBPAPI3_1_0, Http4s400).filterNot(v4 excludeEndpoints) - OBPAPI5_0_0 = collectResourceDocs(OBPAPI4_0_0, Http4s500) - OBPAPI5_1_0 = collectResourceDocs(OBPAPI5_0_0, Http4s510).filterNot(v5.1 excludeEndpoints) - OBPAPI6_0_0 = collectResourceDocs(OBPAPI5_1_0, Http4s600).filterNot(v6 excludeEndpoints) - Http4s700.allResourceDocs = collectResourceDocs(OBPAPI6_0_0, Http4s700) (v7 excludeEndpoints = Nil) - -collectResourceDocs: concatenate, stable-sort by API version DESCENDING, then keep -the first ResourceDoc seen for each (requestUrl, requestVerb) pair. -filterNot: drop any ResourceDoc whose partialFunctionName exactly equals an -excluded name (Scala `String.matches(names.mkString("|"))` is a whole-string match). - -Run: python3 scripts/count_v7_endpoints.py +Statically reproduces Http4s700.allResourceDocs. + +Inputs are derived from source — no hardcoded data tables: + * Version files are discovered by globbing obp-api/src/main/scala/code/api/v*/ + for Http4s{NNN}.scala. All registrations live in Http4s files now — the + older Lift APIMethods*.scala files have been emptied/commented out. + * excludeEndpoints lists are extracted from each version's OBPAPI{a}_{b}_{c}.scala + (or OBPAPI{a}.{b}.{c}.scala for v1.2.1) — and from Http4s700.scala for v7 + which has no OBPAPI counterpart. + +Aggregation chain (matches the runtime code in each OBPAPI{a}_{b}_{c}.scala): + OBPAPI1_2_1.allResourceDocs = Http4s121.resourceDocs (chain root) + OBPAPI{N}.allResourceDocs = collectResourceDocs(OBPAPI{N-1}, Http4s{N}) + .filterNot(excludeEndpoints if any) + Http4s700.allResourceDocs = collectResourceDocs(OBPAPI6_0_0, Http4s700) + .filterNot(v7 excludeEndpoints — currently Nil) + +collectResourceDocs: concat, stable sort by version descending, dedup by (url, verb). +filterNot: drop docs whose partialFunctionName is in the excluded-names set. + +Each run prints a self-check that flags any `\\w*resourceDocs += ResourceDoc` +line that wasn't parsed and isn't obviously commented out — i.e. a new buffer +name, a split-line registration, or a constructor-shape change that broke +parsing. """ -import os +from __future__ import annotations + import re import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SRC = REPO / "obp-api" / "src" / "main" / "scala" / "code" / "api" + +VERSION_DIR_RE = re.compile(r"^v(\d+)_(\d+)_(\d+)$") +REG_START = re.compile(r"^\s*(?:static)?[Rr]esourceDocs\s*\+=\s*ResourceDoc\s*\(") +ANY_REG_REF = re.compile(r"\w*[Rr]esourceDocs\s*\+=\s*ResourceDoc\b") +NAMEOF_RE = re.compile(r"nameOf\s*\(\s*[\w.]*?(\w+)\s*\)") +EXCLUDE_DEF_RE = re.compile(r"(?:lazy\s+)?val\s+excludeEndpoints\b[^=]*=") -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SRC = os.path.join(REPO, "obp-api", "src", "main", "scala", "code", "api") - -# file -> API version (as a sortable (major, minor, patch) tuple) -VERSION_FILES = [ - ("v1_2_1/APIMethods121.scala", (1, 2, 1)), - ("v1_3_0/APIMethods130.scala", (1, 3, 0)), - ("v1_4_0/APIMethods140.scala", (1, 4, 0)), - ("v2_0_0/Http4s200.scala", (2, 0, 0)), - ("v2_1_0/Http4s210.scala", (2, 1, 0)), - ("v2_2_0/Http4s220.scala", (2, 2, 0)), - ("v3_0_0/Http4s300.scala", (3, 0, 0)), - ("v3_1_0/Http4s310.scala", (3, 1, 0)), - ("v4_0_0/Http4s400.scala", (4, 0, 0)), - ("v5_0_0/Http4s500.scala", (5, 0, 0)), - ("v5_1_0/Http4s510.scala", (5, 1, 0)), - ("v6_0_0/Http4s600.scala", (6, 0, 0)), - ("v7_0_0/Http4s700.scala", (7, 0, 0)), -] - -# excludeEndpoints lists, taken verbatim from the OBPAPIxxx.scala source. -# Applied as filterNot(partialFunctionName) at the level keyed below. -EXCLUDE = { - (4, 0, 0): { - "addPermissionForUserForBankAccountForMultipleViews", - "removePermissionForUserForBankAccountForAllViews", - "addPermissionForUserForBankAccountForOneView", - "removePermissionForUserForBankAccountForOneView", - "createAccount", - "revokeConsent", - }, - (5, 1, 0): { - "getUserByUsername", "getBadLoginStatus", "unlockUser", "lockUser", - "createUserWithAccountAccess", "grantUserAccessToView", - "revokeUserAccessToView", "revokeGrantUserAccessToViews", - }, - (6, 0, 0): { - "getUserByUsername", "getBadLoginStatus", "unlockUser", "lockUser", - "createUserWithAccountAccess", "grantUserAccessToView", - "revokeUserAccessToView", "revokeGrantUserAccessToViews", - "getMyPersonalUserAttributes", "createMyPersonalUserAttribute", - "updateMyPersonalUserAttribute", "createNonPersonalUserAttribute", - "getNonPersonalUserAttributes", "deleteNonPersonalUserAttribute", - }, -} - -REG_START = re.compile(r'^\s*(?:static)?[Rr]esourceDocs\s*\+=\s*ResourceDoc\s*\(') HTTP_VERBS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} class Doc: __slots__ = ("verb", "url", "func", "version", "src") - def __init__(self, verb, url, func, version, src): + def __init__(self, verb: str, url: str, func: str, version: tuple, src: str): self.verb, self.url, self.func = verb, url, func self.version, self.src = version, src @@ -91,49 +57,133 @@ def key(self): return (self.url, self.verb) -def split_top_level_args(text, want): +# -- auto-discovery of version files ------------------------------------ + +def discover_version_files() -> list[tuple[tuple, Path]]: + """Find every v*/ directory and resolve its Http4s{NNN}.scala source file. + + Every version's contribution to the aggregation chain lives in its + Http4s{NNN}.scala (the older Lift APIMethods*.scala files have been + commented out and no longer carry registrations). + """ + found = [] + for entry in sorted(SRC.iterdir()): + if not entry.is_dir(): + continue + m = VERSION_DIR_RE.match(entry.name) + if not m: + continue + version = tuple(int(g) for g in m.groups()) + nnn = "".join(str(p) for p in version) + candidate = entry / f"Http4s{nnn}.scala" + if candidate.is_file(): + found.append((version, candidate)) + else: + print( + f"WARN: directory {entry.name} has no {candidate.name} — skipped", + file=sys.stderr, + ) + return found + + +def find_obpapi_file(version: tuple) -> Path | None: + """Locate the file that owns this version's excludeEndpoints list. + + Most versions: OBPAPI{a}_{b}_{c}.scala. v1.2.1 is an outlier + (OBPAPI1.2.1.scala with dots). v7 has no OBPAPI counterpart — its + excludeEndpoints lives in Http4s700.scala. + """ + a, b, c = version + vdir = SRC / f"v{a}_{b}_{c}" + for candidate in ( + vdir / f"OBPAPI{a}_{b}_{c}.scala", + vdir / f"OBPAPI{a}.{b}.{c}.scala", + vdir / f"Http4s{a}{b}{c}.scala", + ): + if candidate.is_file(): + return candidate + return None + + +# -- excludeEndpoints extraction --------------------------------------- + +def extract_excludes(path: Path) -> set[str]: + """Pull names from `(lazy )?val excludeEndpoints = nameOf(...) :: ... :: Nil`. + + Returns the empty set if the val is absent (e.g. v3.0.0, v5.0.0). + """ + lines = path.read_text(encoding="utf-8").splitlines() + start = next((i for i, line in enumerate(lines) if EXCLUDE_DEF_RE.search(line)), None) + if start is None: + return set() + + names: set[str] = set() + in_block_comment = False + for raw in lines[start:]: + line = raw + # close an open block comment + if in_block_comment: + end = line.find("*/") + if end == -1: + continue + line = line[end + 2:] + in_block_comment = False + # open block comment that doesn't close on this line + if "/*" in line and "*/" not in line[line.find("/*"):]: + line = line[: line.find("/*")] + in_block_comment = True + # line comment + if "//" in line: + line = line[: line.find("//")] + names.update(NAMEOF_RE.findall(line)) + if line.strip() in {"Nil", "Nil)"} or line.rstrip().endswith("Nil"): + break + return names + + +# -- registration parsing ---------------------------------------------- + +def split_top_level_args(text: str, want: int) -> list[str]: """Return the first `want` comma-separated args of a Scala call. - `text` starts just after the opening '(' of ResourceDoc(...). Tracks string - literals (incl. triple-quoted), nested brackets, and // and /* */ comments - so commas inside them are not treated as separators. + `text` begins just after the opening '(' of ResourceDoc(...). Tracks single- + and triple-quoted strings, nested brackets, // line comments and /* */ block + comments so commas inside any of those aren't treated as separators. """ args, buf = [], [] depth = 0 i, n = 0, len(text) while i < n and len(args) < want: - c = text[i] - two = text[i:i + 2] - three = text[i:i + 3] - if three == '"""': # triple-quoted string + c, two, three = text[i], text[i:i + 2], text[i:i + 3] + if three == '"""': end = text.find('"""', i + 3) if end == -1: buf.append(text[i:]); break buf.append(text[i:end + 3]); i = end + 3; continue - if c == '"': # single-quoted string + if c == '"': j = i + 1 while j < n: - if text[j] == '\\': + if text[j] == "\\": j += 2; continue if text[j] == '"': break j += 1 buf.append(text[i:j + 1]); i = j + 1; continue - if two == '//': # line comment - end = text.find('\n', i) + if two == "//": + end = text.find("\n", i) i = n if end == -1 else end continue - if two == '/*': # block comment - end = text.find('*/', i + 2) + if two == "/*": + end = text.find("*/", i + 2) i = n if end == -1 else end + 2 continue - if c in '([{': + if c in "([{": depth += 1; buf.append(c); i += 1; continue - if c in ')]}': - if depth == 0: # closing ResourceDoc( + if c in ")]}": + if depth == 0: break depth -= 1; buf.append(c); i += 1; continue - if c == ',' and depth == 0: + if c == "," and depth == 0: args.append("".join(buf)); buf = []; i += 1; continue buf.append(c); i += 1 if buf and len(args) < want: @@ -141,9 +191,9 @@ def split_top_level_args(text, want): return args -def strip_str(literal): - s = literal.strip() - if s.startswith("s"): # interpolator prefix +def strip_str_literal(token: str) -> str: + s = token.strip() + if s.startswith("s"): s = s[1:].strip() if s.startswith('"""') and s.endswith('"""'): return s[3:-3] @@ -152,37 +202,63 @@ def strip_str(literal): return s -def parse_file(path, version): - with open(path, encoding="utf-8") as fh: - lines = fh.readlines() - docs = [] +def parse_file(path: Path, version: tuple) -> tuple[list[Doc], set[int]]: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + docs, parsed_lines = [], set() for idx, line in enumerate(lines): if not REG_START.match(line): continue - # gather a generous window of source from the opening paren onward + parsed_lines.add(idx) chunk = "".join(lines[idx:idx + 60]) chunk = chunk[chunk.index("ResourceDoc") + len("ResourceDoc"):] chunk = chunk[chunk.index("(") + 1:] args = split_top_level_args(chunk, want=5) if len(args) < 5: - print(f" WARN: could not parse args at {path}:{idx + 1}", file=sys.stderr) + print(f" WARN: incomplete args at {path.name}:{idx + 1}", file=sys.stderr) continue - func_arg, verb_arg, url_arg = args[2], args[3], args[4] - m = re.search(r'nameOf\s*\(\s*[\w.]*?(\w+)\s*\)', func_arg) - func = m.group(1) if m else strip_str(func_arg) - verb = strip_str(verb_arg).upper() - url = strip_str(url_arg) + m = NAMEOF_RE.search(args[2]) + func = m.group(1) if m else strip_str_literal(args[2]) + verb = strip_str_literal(args[3]).upper() + url = strip_str_literal(args[4]) if verb not in HTTP_VERBS: - print(f" WARN: unexpected verb {verb!r} at {path}:{idx + 1}", file=sys.stderr) + print(f" WARN: unexpected verb {verb!r} at {path.name}:{idx + 1}", + file=sys.stderr) continue - docs.append(Doc(verb, url, func, version, f"{os.path.basename(path)}:{idx + 1}")) - return docs + docs.append(Doc(verb, url, func, version, f"{path.name}:{idx + 1}")) + return docs, parsed_lines -def collect(*buckets): - """Reproduce collectResourceDocs: concat, stable sort by version desc, dedup by (url,verb).""" +# -- self-check -------------------------------------------------------- + +def self_check(path: Path, parsed_lines: set[int]) -> list[str]: + """Every `\\w*resourceDocs += ResourceDoc` line must be either parsed by + the script, a `// resourceDocs += ResourceDoc` commented-out registration, + or an inline-comment mention (// comes before the pattern).""" + warnings = [] + for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines()): + if not ANY_REG_REF.search(line): + continue + if idx in parsed_lines: + continue + stripped = line.lstrip() + if stripped.startswith("//"): + continue + comment_at = line.find("//") + ref_at = line.find("resourceDocs") + # `resourceDocs` may appear after `static`; fall back to a robust check + if ref_at == -1: + ref_at = line.find("ResourceDocs") + if comment_at != -1 and comment_at < ref_at: + continue + warnings.append(f" {path.name}:{idx + 1}: {line.rstrip()}") + return warnings + + +# -- aggregation ------------------------------------------------------- + +def collect(*buckets: list[Doc]) -> list[Doc]: merged = [d for bucket in buckets for d in bucket] - merged.sort(key=lambda d: d.version, reverse=True) # stable + merged.sort(key=lambda d: d.version, reverse=True) # stable seen, out = set(), [] for d in merged: if d.key() not in seen: @@ -191,80 +267,96 @@ def collect(*buckets): return out -def filter_not(docs, excluded_names): - return [d for d in docs if d.func not in excluded_names] +# -- main -------------------------------------------------------------- + +def main() -> None: + version_files = discover_version_files() + if not version_files: + sys.exit("ERROR: no v*/Http4s*.scala or APIMethods*.scala files found") + by_version: dict[tuple, list[Doc]] = {} + self_check_warnings: list[str] = [] -def main(): - by_version = {} - print("Parsing ResourceDoc registrations:") - for rel, version in VERSION_FILES: - path = os.path.join(SRC, rel) - if not os.path.isfile(path): - sys.exit(f"ERROR: missing source file {path}") - docs = parse_file(path, version) + print("Parsing ResourceDoc registrations (auto-discovered):") + for version, path in version_files: + docs, parsed = parse_file(path, version) by_version[version] = docs - print(f" {rel:30s} v{'.'.join(map(str, version))}: {len(docs):4d} docs") + self_check_warnings += self_check(path, parsed) + print(f" v{'.'.join(map(str, version)):<8s} " + f"{path.relative_to(REPO)} {len(docs):4d} docs") print() - # OBPAPI1_4_0.allResourceDocs = plain ++ (no dedup, no filter) - level = by_version[(1, 4, 0)] + by_version[(1, 3, 0)] + by_version[(1, 2, 1)] - - steps = [ - ((2, 0, 0), None), ((2, 1, 0), None), ((2, 2, 0), None), - ((3, 0, 0), None), ((3, 1, 0), None), - ((4, 0, 0), EXCLUDE[(4, 0, 0)]), - ((5, 0, 0), None), - ((5, 1, 0), EXCLUDE[(5, 1, 0)]), - ((6, 0, 0), EXCLUDE[(6, 0, 0)]), - ((7, 0, 0), None), - ] - excluded_log = [] - for version, excl in steps: + excludes: dict[tuple, set[str]] = {} + print("Extracting excludeEndpoints lists from source:") + for version, _ in version_files: + owner = find_obpapi_file(version) + if owner is None: + continue + ex = extract_excludes(owner) + if ex: + excludes[version] = ex + print(f" v{'.'.join(map(str, version))}: " + f"{len(ex)} excludes ({owner.relative_to(REPO)})") + print() + + # Chain root: OBPAPI1_2_1.allResourceDocs = Http4s121.resourceDocs (no concat). + # Every later version: collectResourceDocs(prev, this) [.filterNot(excludes)]. + versions_asc = sorted(by_version.keys()) + if not versions_asc: + sys.exit("ERROR: no parseable version files") + level: list[Doc] = list(by_version[versions_asc[0]]) + excluded_log: list[tuple[tuple, Doc]] = [] + for version in versions_asc[1:]: level = collect(level, by_version[version]) - if excl: - removed = [d for d in level if d.func in excl] + if version in excludes: + removed = [d for d in level if d.func in excludes[version]] excluded_log += [(version, d) for d in removed] - level = filter_not(level, excl) + level = [d for d in level if d.func not in excludes[version]] final = level - - # transparency: did any exclusion remove a (url,verb) that another doc also serves? - final_keys = {d.key() for d in final} - shadow = [(v, d) for v, d in excluded_log if d.key() in final_keys] + own_v7 = len(by_version.get((7, 0, 0), [])) print("=" * 66) print("OBP endpoints reachable via /obp/v7.0.0/") print("=" * 66) - print(f"v7.0.0 native (http4s) endpoints : {len(by_version[(7,0,0)]):4d}") + print(f"v7.0.0 native (http4s) endpoints : {own_v7:4d}") print(f"Total reachable (aggregated + deduped) : {len(final):4d}") print() - wins = {} + wins: dict[tuple, int] = {} for d in final: wins[d.version] = wins.get(d.version, 0) + 1 print("Owned by version (newest wins on URL+verb clash):") - for version in sorted(wins, reverse=True): - print(f" v{'.'.join(map(str, version)):<14s} {wins[version]:4d}") + for v in sorted(wins, reverse=True): + print(f" v{'.'.join(map(str, v)):<14s} {wins[v]:4d}") print() - verbs = {} + verbs: dict[str, int] = {} for d in final: verbs[d.verb] = verbs.get(d.verb, 0) + 1 print("By HTTP method:") - for verb in sorted(verbs, key=lambda v: -verbs[v]): + for verb in sorted(verbs, key=lambda k: -verbs[k]): print(f" {verb:<8s} {verbs[verb]:4d}") print() - print(f"Endpoints removed by excludeEndpoints filters: {len(excluded_log)}") - for version, d in excluded_log: - print(f" v{'.'.join(map(str, version))} drops {d.verb:6s} {d.url} ({d.func})") - if shadow: + if excluded_log: + final_keys = {d.key() for d in final} + print(f"Endpoints removed by excludeEndpoints filters: {len(excluded_log)}") + for version, d in excluded_log: + tag = " (key re-added by a later version)" if d.key() in final_keys else "" + print(f" v{'.'.join(map(str, version))} drops " + f"{d.verb:6s} {d.url} ({d.func}){tag}") print() - print("NOTE: an excluded endpoint shared a (url,verb) still served by " - "another doc — count may need a closer look:") - for version, d in shadow: - print(f" {d.verb} {d.url} ({d.func})") + + if self_check_warnings: + print("Self-check FAILED — unaccounted registration lines " + "(possible new buffer name, split-line registration, or " + "constructor-shape change):") + for w in self_check_warnings: + print(w) + sys.exit(1) + print("Self-check: every `resourceDocs += ResourceDoc` reference " + "is parsed or visibly commented.") if __name__ == "__main__": From d8a5cc523be21716b83e631b060005eb0c851a14 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 23 May 2026 12:27:42 +0200 Subject: [PATCH 6/7] Making consent_reference_id a UUID Includes two migrations. --- .../scala/code/api/util/ExampleValue.scala | 4 +- .../code/api/util/migration/Migration.scala | 13 ++ .../MigrationOfConsentReferenceIdUuid.scala | 136 ++++++++++++++++++ .../migration/MigrationOfConsentView.scala | 4 +- .../main/scala/code/consent/ConsentItem.scala | 2 +- .../code/consent/DoobieConsentQueries.scala | 4 +- .../scala/code/consent/MappedConsent.scala | 11 +- 7 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala diff --git a/obp-api/src/main/scala/code/api/util/ExampleValue.scala b/obp-api/src/main/scala/code/api/util/ExampleValue.scala index 898079fa68..a2b974e732 100644 --- a/obp-api/src/main/scala/code/api/util/ExampleValue.scala +++ b/obp-api/src/main/scala/code/api/util/ExampleValue.scala @@ -1630,8 +1630,8 @@ object ExampleValue { lazy val directDebitIdExample = ConnectorField(NoExampleProvided,NoDescriptionProvided) glossaryItems += makeGlossaryItem("direct_debit_id", directDebitIdExample) - lazy val consentReferenceIdExample = ConnectorField("123456" ,NoDescriptionProvided) - glossaryItems += makeGlossaryItem("consent_id", consentReferenceIdExample) + lazy val consentReferenceIdExample = ConnectorField("fd13b9af-4f74-4d52-a7f1-7c2c12f3aa11" ,NoDescriptionProvided) + glossaryItems += makeGlossaryItem("consent_reference_id", consentReferenceIdExample) lazy val consentIdExample = ConnectorField("9d429899-24f5-42c8-8565-943ffa6a7947",NoDescriptionProvided) glossaryItems += makeGlossaryItem("consent_id", consentIdExample) diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index f9bd3939cf..698a48edf8 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -118,6 +118,7 @@ object Migration extends MdcLoggable { updateAccountAccessWithViewsViewUnionAll(startedBeforeSchemifier) migrateChatRoomIsOpenRoom() migrateChatRoomCreatedByAndLastMessageSender() + migrateConsentReferenceIdToUuid(startedBeforeSchemifier) } private def dummyScript(): Boolean = { @@ -636,6 +637,18 @@ object Migration extends MdcLoggable { } } + private def migrateConsentReferenceIdToUuid(startedBeforeSchemifier: Boolean): Boolean = { + if(startedBeforeSchemifier == true) { + logger.warn(s"Migration.database.migrateConsentReferenceIdToUuid(true) cannot be run before Schemifier.") + true + } else { + val name = nameOf(migrateConsentReferenceIdToUuid(startedBeforeSchemifier)) + runOnce(name) { + MigrationOfConsentReferenceIdUuid.migrate(name) + } + } + } + private def addAccountAccessWithViewsView(startedBeforeSchemifier: Boolean): Boolean = { if(startedBeforeSchemifier == true) { logger.warn(s"Migration.database.addAccountAccessWithViewsView(true) cannot be run before Schemifier.") diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala new file mode 100644 index 0000000000..de22a37f48 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala @@ -0,0 +1,136 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.consent.MappedConsent +import net.liftweb.mapper.Schemifier + +/** + * Migration: switch consent_reference_id from a stringified row PK (Long) to a UUID. + * + * On EXISTING deploys, this: + * 1. Backs up `mappedconsent` and `consent_item` (snapshot tables prefixed `backup_2026_05_`). + * 2. Drops the v_consent view (its column types are about to change). + * 3. Adds `mappedconsent.consent_reference_id VARCHAR(36)` (nullable for now). + * 4. Backfills every existing consent row with a fresh UUID. + * 5. Adds NOT NULL + UNIQUE on the new column. + * 6. Widens `consent_item.consent_reference_id` from BIGINT to VARCHAR(36). + * 7. Rewrites every consent_item row's consent_reference_id to point at its parent + * consent's new UUID (joining on the old Long via ::text cast). + * + * v_consent gets recreated by `MigrationOfConsentView.addConsentView` (already in the registry + * with this migration's updated projection), wrapped here so it's part of the same logical step. + * + * On FRESH deploys, Schemifier creates everything with the correct shape directly from the + * Scala model. The `tableExists` guards make most steps no-ops; backup CREATEs against an + * empty table produce empty backup tables, which is harmless. + */ +object MigrationOfConsentReferenceIdUuid { + + def migrate(name: String): Boolean = { + DbFunction.tableExists(MappedConsent) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val dbDriver = APIUtil.getPropsValue("db.driver") openOr "org.h2.Driver" + val isMssql = dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") + var isSuccessful = false + val sqlLog = new StringBuilder() + + try { + // 1. Backups — table-snapshot copies so we can recover from any later failure. + val backupMappedConsent = if (isMssql) { + "SELECT * INTO backup_2026_05_mappedconsent FROM mappedconsent;" + } else { + "CREATE TABLE backup_2026_05_mappedconsent AS SELECT * FROM mappedconsent;" + } + val backupConsentItem = if (isMssql) { + "SELECT * INTO backup_2026_05_consent_item FROM consent_item;" + } else { + "CREATE TABLE backup_2026_05_consent_item AS SELECT * FROM consent_item;" + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backupMappedConsent)).append("\n") + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backupConsentItem)).append("\n") + + // 2. Drop the existing v_consent view; its consent_reference_id column type is changing. + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => "DROP VIEW IF EXISTS v_consent;")).append("\n") + + // 3. Add the new UUID column on mappedconsent, nullable for the backfill. + val addColumn = if (isMssql) { + "ALTER TABLE mappedconsent ADD consent_reference_id VARCHAR(36) NULL;" + } else { + "ALTER TABLE mappedconsent ADD COLUMN IF NOT EXISTS consent_reference_id VARCHAR(36);" + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => addColumn)).append("\n") + + // 4. Backfill every existing consent row with a fresh UUID. + val backfillConsents = if (isMssql) { + "UPDATE mappedconsent SET consent_reference_id = LOWER(CONVERT(varchar(36), NEWID())) WHERE consent_reference_id IS NULL;" + } else { + // gen_random_uuid() requires pgcrypto on older Postgres; built-in on PG13+. + "UPDATE mappedconsent SET consent_reference_id = gen_random_uuid()::text WHERE consent_reference_id IS NULL;" + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backfillConsents)).append("\n") + + // 5. Enforce NOT NULL + UNIQUE. + val setNotNull = if (isMssql) { + "ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id VARCHAR(36) NOT NULL;" + } else { + "ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id SET NOT NULL;" + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => setNotNull)).append("\n") + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => + "ALTER TABLE mappedconsent ADD CONSTRAINT uq_mappedconsent_consent_reference_id UNIQUE (consent_reference_id);" + )).append("\n") + + // 6. Widen consent_item.consent_reference_id from BIGINT to VARCHAR(36). + val alterConsentItem = if (isMssql) { + "ALTER TABLE consent_item ALTER COLUMN consent_reference_id VARCHAR(36);" + } else { + "ALTER TABLE consent_item ALTER COLUMN consent_reference_id TYPE VARCHAR(36) USING consent_reference_id::text;" + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => alterConsentItem)).append("\n") + + // 7. Rewrite each consent_item row to point at its parent consent's new UUID. + // Old value is the parent's row PK (id) stringified; join on that, write the UUID. + val backfillConsentItem = if (isMssql) { + """UPDATE ci SET ci.consent_reference_id = c.consent_reference_id + |FROM consent_item ci + |JOIN mappedconsent c ON ci.consent_reference_id = CAST(c.id AS VARCHAR(36));""".stripMargin + } else { + """UPDATE consent_item ci + |SET consent_reference_id = c.consent_reference_id + |FROM mappedconsent c + |WHERE ci.consent_reference_id = c.id::text;""".stripMargin + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backfillConsentItem)).append("\n") + + // 8. Recreate v_consent with the new column projection. + MigrationOfConsentView.addConsentView(name + "_view_rebuild") + + isSuccessful = true + } catch { + case e: Exception => + isSuccessful = false + sqlLog.append(s"\nException: ${e.getMessage}\n") + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$sqlLog + |""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = s"""${MappedConsent._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } +} diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentView.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentView.scala index 2bf404de61..fd78410e6f 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentView.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentView.scala @@ -22,7 +22,7 @@ object MigrationOfConsentView { """ |CREATE OR ALTER VIEW v_consent AS |SELECT - | id AS consent_reference_id, + | consent_reference_id AS consent_reference_id, | mconsentid AS consent_id, | muserid AS created_by_user_id, | mconsumerid AS consumer_id, @@ -46,7 +46,7 @@ object MigrationOfConsentView { """ |CREATE OR REPLACE VIEW v_consent AS |SELECT - | id AS consent_reference_id, + | consent_reference_id AS consent_reference_id, | mconsentid AS consent_id, | muserid AS created_by_user_id, | mconsumerid AS consumer_id, diff --git a/obp-api/src/main/scala/code/consent/ConsentItem.scala b/obp-api/src/main/scala/code/consent/ConsentItem.scala index 66403d81df..d1252df093 100644 --- a/obp-api/src/main/scala/code/consent/ConsentItem.scala +++ b/obp-api/src/main/scala/code/consent/ConsentItem.scala @@ -12,7 +12,7 @@ class ConsentItem extends LongKeyedMapper[ConsentItem] with IdPK { object consentItemId extends MappedUUID(this) { override def dbColumnName = "consent_item_id" } - object consentReferenceId extends MappedLong(this) { + object consentReferenceId extends MappedString(this, 36) { override def dbColumnName = "consent_reference_id" } object itemType extends MappedString(this, 64) { diff --git a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala index 0be3652481..7bfb7e413c 100644 --- a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala +++ b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala @@ -24,7 +24,7 @@ object DoobieConsentQueries { * Fields align with ConsentInfoJsonV510. */ case class ConsentRow( - consentReferenceId: Long, + consentReferenceId: String, consentId: String, createdByUserId: String, consumerId: Option[String], @@ -179,7 +179,7 @@ object DoobieConsentQueries { * Insert consent items from the consent's views and entitlements. * Called at consent creation time after the JWT is set. */ - def insertConsentItems(consentReferenceId: Long, consentJWT: code.api.util.ConsentJWT): Unit = { + def insertConsentItems(consentReferenceId: String, consentJWT: code.api.util.ConsentJWT): Unit = { val viewInserts = consentJWT.views.filter(_.bank_id.nonEmpty).map { view => val consentItemId = java.util.UUID.randomUUID().toString val itemType = "VIEW" diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 34eae3c43f..da69ce585e 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -333,7 +333,7 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo result.foreach { savedConsent => try { consentJWTParsed.foreach { consentJWT => - DoobieConsentQueries.insertConsentItems(savedConsent.id.get, consentJWT) + DoobieConsentQueries.insertConsentItems(savedConsent.mConsentReferenceId.get, consentJWT) } } catch { case e: Exception => @@ -475,6 +475,11 @@ class MappedConsent extends ConsentTrait with LongKeyedMapper[MappedConsent] wit object mJwtExpiresAt extends MappedDateTime(this) { override def dbColumnName = "jwt_expires_at" } + // Stable external identifier for the consent — referenced by consent_item.consent_reference_id + // and surfaced in v5.1.0 JSON. Replaces the historical derivation from the row PK. + object mConsentReferenceId extends MappedUUID(this) { + override def dbColumnName = "consent_reference_id" + } override def consentId: String = mConsentId.get override def userId: String = mUserId.get @@ -503,11 +508,11 @@ class MappedConsent extends ConsentTrait with LongKeyedMapper[MappedConsent] wit override def transactionToDateTime= mTransactionToDateTime.get override def creationDateTime= createdAt.get override def statusUpdateDateTime= mStatusUpdateDateTime.get - override def consentReferenceId = id.get.toString + override def consentReferenceId = mConsentReferenceId.get override def note = mNote.get } object MappedConsent extends MappedConsent with LongKeyedMetaMapper[MappedConsent] { - override def dbIndexes = UniqueIndex(mConsentId) :: Index(mUserId) :: Index(mUserId, createdAt) :: super.dbIndexes + override def dbIndexes = UniqueIndex(mConsentId) :: UniqueIndex(mConsentReferenceId) :: Index(mUserId) :: Index(mUserId, createdAt) :: super.dbIndexes } From 0fd3ab8f91f816fd9a06052ec2576de515fdb889 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 23 May 2026 13:02:41 +0200 Subject: [PATCH 7/7] Fix MigrationOfConsentReferenceIdUuid for H2 --- .../MigrationOfConsentReferenceIdUuid.scala | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala index de22a37f48..5a1a05e58f 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala @@ -28,11 +28,25 @@ import net.liftweb.mapper.Schemifier object MigrationOfConsentReferenceIdUuid { def migrate(name: String): Boolean = { + val dbDriver = APIUtil.getPropsValue("db.driver") openOr "org.h2.Driver" + val isH2 = dbDriver.contains("org.h2.Driver") + // H2 is only used in tests. Schemifier builds a fresh schema each run with the new + // column types already in place (MappedConsent.mConsentReferenceId via MappedUUID, + // ConsentItem.consentReferenceId via MappedString(36)), and v_consent is recreated by + // addConsentView earlier in the migration chain. Running the Postgres/MSSQL ALTERs + // here would only break the fresh schema — gen_random_uuid()/USING-cast/UPDATE-FROM + // are not H2 syntax, and a failure mid-way drops v_consent without recreating it. + if (isH2) { + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val endDate = System.currentTimeMillis() + saveLog(name, commitId, true, startDate, endDate, "H2 detected — fresh schema already has the new column shape; nothing to migrate.") + return true + } DbFunction.tableExists(MappedConsent) match { case true => val startDate = System.currentTimeMillis() val commitId: String = APIUtil.gitCommit - val dbDriver = APIUtil.getPropsValue("db.driver") openOr "org.h2.Driver" val isMssql = dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") var isSuccessful = false val sqlLog = new StringBuilder()