From 0318885ab7b6721ae841eb97dfe063fe56babdf7 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 1 Sep 2026 08:29:46 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Added=20auth=5Ftype=20metrics=20column=20an?= =?UTF-8?q?y=20grant=20targeting=20a=20consent=20user=20redirects=20to=20i?= =?UTF-8?q?ts=20granting=20human=20with=20a=20warn-log;=20sole=20exemption?= =?UTF-8?q?=20is=20createdByProcess=20=3D=3D=20consent=5Fuser.=20ConsentUt?= =?UTF-8?q?il.addEntitlements=20tags=20its=20writes=20accordingly.=20=20?= =?UTF-8?q?=20-=20process=20column=20retired:=20parameter=20and=20accessor?= =?UTF-8?q?=20removed=20from=20the=20=20=20=20=20Entilement=20trait=20and?= =?UTF-8?q?=20implementation=20=20=20=20=20group=20queries=20now=20key=20o?= =?UTF-8?q?n=20group=5Fid;=20GroupEntitlementJsonV600=20exposes=20=20=20?= =?UTF-8?q?=20=20created=5Fby=5Fprocess=20instead=20of=20process.=20=20=20?= =?UTF-8?q?-=20Explicit-target=20guards=20(400,=20"=E2=80=A6names=20a=20co?= =?UTF-8?q?nsent=20user=E2=80=A6"):=20=20=20=20=20addEntitlement=20v2.0.0?= =?UTF-8?q?=20+=20v7.0.0;=20addUserToGroup=20v6;=20=20=20=20=20grantUserAc?= =?UTF-8?q?cessToViewById=20v5.1;=20createAccountAccessRequest=20v6=20=20?= =?UTF-8?q?=20=20=20(reject=20at=20creation)=20plus=20repeated=20check=20a?= =?UTF-8?q?t=20its=20approval;=20=20=20=20=20createAccount=20endpoints=20v?= =?UTF-8?q?2.0.0,=20v3.1.0,=20v4.0.0=20(regular=20+=20=20=20=20=20settleme?= =?UTF-8?q?nt),=20v5.0.0,=20v7.0.0.=20=20=20-=20Implicit-target=20resoluti?= =?UTF-8?q?on=20to=20the=20accountable=20user=20(currently=20a=20=20=20=20?= =?UTF-8?q?=20human):=20bank-creator=20grants=20(v2.2,=20v5,=20v6,=20v7=20?= =?UTF-8?q?incl.=20the=20=20=20=20=20generated-bank=20endpoint),=20v6=20?= =?UTF-8?q?=20=20=20=20dynamic-entity=20creator=20roles,=20v3.0.0=20entitl?= =?UTF-8?q?ement-request=20requester,=20=20=20=20=20createAccount=20owner?= =?UTF-8?q?=20fallbacks,=20and=20the=20connector-internal=20=20=20=20=20HO?= =?UTF-8?q?LDING-account=20holder.=20=20=20-=20Rename:=20effectiveHumanUse?= =?UTF-8?q?rId=20=E2=86=92=20accountableUserId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SwaggerDefinitionsJSON.scala | 1 + .../scala/code/api/constant/constant.scala | 15 ++++ .../main/scala/code/api/util/ApiSession.scala | 25 +++++-- .../scala/code/api/util/ConsentUtil.scala | 7 +- .../scala/code/api/util/WriteMetricUtil.scala | 33 ++++++++- .../code/api/util/migration/Migration.scala | 13 ++++ .../MigrationOfActivityDashboardIndexes.scala | 4 +- .../migration/MigrationOfMetricAuthType.scala | 74 +++++++++++++++++++ .../scala/code/api/v2_0_0/Http4s200.scala | 16 +++- .../scala/code/api/v2_2_0/Http4s220.scala | 9 ++- .../scala/code/api/v3_0_0/Http4s300.scala | 8 +- .../scala/code/api/v3_1_0/Http4s310.scala | 8 +- .../scala/code/api/v4_0_0/Http4s400.scala | 16 +++- .../scala/code/api/v5_0_0/Http4s500.scala | 17 ++++- .../scala/code/api/v5_1_0/Http4s510.scala | 6 ++ .../scala/code/api/v6_0_0/Http4s600.scala | 57 ++++++++++---- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 9 ++- .../scala/code/api/v7_0_0/Http4s700.scala | 32 +++++--- .../LocalMappedConnectorInternal.scala | 8 +- .../scala/code/entitlement/Entilement.scala | 4 +- .../code/entitlement/MappedEntitlements.scala | 52 +++++++++---- .../main/scala/code/metrics/APIMetrics.scala | 11 ++- .../code/metrics/ElasticsearchMetrics.scala | 6 +- .../scala/code/metrics/MappedMetrics.scala | 28 ++++++- .../code/metrics/MetricBatchWriter.scala | 12 +-- .../code/model/dataAccess/ResourceUser.scala | 2 +- .../scheduler/MetricsArchiveScheduler.scala | 3 +- .../code/api/util/AgentDelegationTest.scala | 14 ++-- .../code/api/v6_0_0/MetricAuthTypeTest.scala | 60 +++++++++++++++ .../test/scala/code/metrics/MetricsTest.scala | 18 ++--- 30 files changed, 464 insertions(+), 104 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricAuthType.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/MetricAuthTypeTest.scala diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 2ca0eeef91..a3c8cac50a 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -3202,6 +3202,7 @@ object SwaggerDefinitionsJSON { operation_id = "OBPv4.0.0-getBanks", api_instance_id = "obp_node_a", consent_reference_id = Some(ExampleValue.consentReferenceIdExample.value), + auth_type = Some("Consent"), certificate_trust = Some("forwarded"), certificate_trust_detail = Some("cn=nginx-prod-1,ou=edge,o=tesobe gmbh,c=de") ) 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 d4bfed683b..2cb79f2af5 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -16,6 +16,21 @@ object Constant extends MdcLoggable { final val directLoginHeaderName = "DirectLogin" + // createdByProcess of entitlement rows the consent engine copies onto a consent user — + // the per-consent principal a Consent-JWT authenticates as (its ResourceUser row carries + // CreatedByConsentId). Only rows tagged with this value may target a consent user: + // addEntitlement redirects any other grant to the consent's granting human, so durable + // roles (e.g. bank-creator grants) can never strand on a principal that dies with its + // consent. Also the marker for cleaning these rows up when the consent is revoked. + final val consent_user = "consent_user" + + // createdByProcess of entitlement rows granted through group membership (the Groups + // feature). The value predates this constant: the Groups feature originally wrote it to + // its own `process` column, a duplicate of createdByProcess since retired — provenance + // now lives in createdByProcess like every other granting mechanism, and group rows are + // identified by their group_id. + final val group_membership = "GROUP_MEMBERSHIP" + object Pagination { final val offset = 0 final val limit = 50 diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index cdd812050d..fd125ef95e 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -39,7 +39,7 @@ case class CallContext( // the creator is the granting human (they create their own consent in the Portal). // Not set by Berlin Group / UK flows, where the consent may be created by a TPP flow // with no human logged in — see `consenter` for those. - // Read via humanUser / effectiveHumanUserId, where it takes precedence over consenter. + // Read via humanUser / accountableUserId, where it takes precedence over consenter. onBehalfOfUser: Box[User] = Empty, // The human (PSU) who AUTHORISED the consent this request runs under — the owner of // record, from the consent table's userId (bound by updateConsentUser during the @@ -113,7 +113,7 @@ case class CallContext( * Anything that must name a human rather than a principal reads this instead: the CBS adapter, * which tells the core banking system who is asking, and the consent ownership checks. * Stored data (metric rows included) always carries the authenticated principal; the human is - * resolved at read time via the consent table (see effectiveHumanUserId). + * resolved at read time via the consent table (see accountableUserId). */ def humanUser: Box[User] = onBehalfOfUser.or(consenter).or(user) @@ -182,7 +182,7 @@ case class CallContext( // (CallContext.user), never a resolved human. Under a consent that principal is the // consent's own shadow user (a per-consent UUID with an empty name) — the on-behalf-of // human is not stored here but resolved at read time via the consent table - // (consentReferenceId below -> consent.userId), see CallContext.effectiveHumanUserId. + // (consentReferenceId below -> consent.userId), see CallContext.accountableUserId. userId = this.user.map(_.userId).toOption, userName = this.user.map(_.name).toOption, consumerId = this.consumer.map(_.consumerId.get).toOption, @@ -217,22 +217,31 @@ case class CallContext( def userId: String = user.map(_.userId).openOrThrowException(AuthenticatedUserIsRequired) /** - * The human User this request is really about. + * The ACCOUNTABLE identity this request is really about — the user_id that durable + * state (creator role grants, account holders, entitlement requests) and attribution + * (metrics families, "my" queries) bind to. "Accountable" deliberately hints at a + * legal person: today resolution always ends at the human who granted the consent, + * but the contract is accountability, not species — if durable, sponsored agent + * identities are ever admitted as principals in their own right, resolution may stop + * at such an agent without this name becoming a lie (unlike the previous name, + * effectiveHumanUserId). * - * The authenticated `user` may be the human themselves, or an agent user minted by a - * Consent the human granted (e.g. Opey / MCP acting under a consent). Resolution order: + * The authenticated `user` may be the accountable party themselves, or a consent user + * minted by a Consent they granted (e.g. Opey / MCP acting under a consent) — consent + * users are ephemeral and must never hold durable state (see addEntitlement's guard). + * Resolution order: * 1. `onBehalfOfUser` or `consenter`, when a middleware populated them (free); * 2. otherwise resolve via the delegation registry: the caller's ResourceUser row's * CreatedByConsentId names the Consent that minted it, and that Consent's userId * names the granting human; - * 3. otherwise the caller IS the human. + * 3. otherwise the caller IS the accountable party. * * IMPORTANT: this reads only the authenticated user and server-written columns * (ResourceUser.CreatedByConsentId, MappedConsent.mUserId). It deliberately takes no * parameters so nothing caller-asserted (body/header/query values) can ever influence * the resolution — identity-sensitive queries (e.g. /my/banks) depend on that. */ - def effectiveHumanUserId: String = { + def accountableUserId: String = { val delegatedHumanUserId = onBehalfOfUser.or(consenter).map(_.userId).filter(_.nonEmpty) delegatedHumanUserId.openOr { val authenticatedUserId = user.map(_.userId).openOr("") diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index f55cd0cfb3..f17d281d67 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -380,7 +380,10 @@ object Consent extends MdcLoggable { existingEntitlements.exists(_.roleName == entitlement.role_name) match { // Check is a role already added to a user case false => val bankId = if (role.requiresBankId) entitlement.bank_id else "" - Entitlement.entitlement.vend.addEntitlement(bankId, user.userId, entitlement.role_name) match { + // Tagged consent_user: this is the ONE writer allowed to target a consent + // user — addEntitlement redirects untagged grants to the granting human. + Entitlement.entitlement.vend.addEntitlement(bankId, user.userId, entitlement.role_name, + createdByProcess = Constant.consent_user) match { case Full(_) => (entitlement, "AddedOrExisted") case _ => (entitlement, CannotAddEntitlement + entitlement) @@ -905,7 +908,7 @@ object Consent extends MdcLoggable { } yield { (principal, callContext.copy( // The PSU stays reachable for everything that needs a human: the CBS adapter, metric - // attribution, and CallContext.effectiveHumanUserId. + // attribution, and CallContext.accountableUserId. consenter = Full(psu), ukConsentId = Some(storedConsent.consentId), consentReferenceId = Some(storedConsent.consentReferenceId) diff --git a/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala b/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala index 8e7768e92a..5af1eb65ae 100644 --- a/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala +++ b/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala @@ -45,7 +45,8 @@ object WriteMetricUtil extends MdcLoggable { duration: Long, responseBodyToWrite: String, sourceIp: String, - targetIp: String) + targetIp: String, + authType: String) private def persistAndPublishMetric(responseBody: Any, cc: CallContextLight): Unit = { val fields = MetricFields( @@ -58,7 +59,8 @@ object WriteMetricUtil extends MdcLoggable { duration = callDuration(cc), responseBodyToWrite = responseBodyForMetric(responseBody, cc), sourceIp = requestHeaderValue(cc, "x-forwarded-for"), - targetIp = requestHeaderValue(cc, "x-forwarded-host") + targetIp = requestHeaderValue(cc, "x-forwarded-host"), + authType = deriveAuthType(cc) ) // enqueue synchronously so flush() in tests reliably drains this metric before assertions @@ -74,6 +76,30 @@ object WriteMetricUtil extends MdcLoggable { } } + /** + * Authentication SCHEME of the call — never the credential itself. "Consent" wins + * outright: when a consent authenticated the call, the Authorization header (if any) + * was not what authorized it. The rest is read off the Authorization header shape, + * with the gateway payload / direct-login params as fallbacks for flows that + * populate those without a header. + */ + private[util] def deriveAuthType(cc: CallContextLight): String = { + if (cc.consentReferenceId.isDefined) "Consent" + else cc.authReqHeaderField.map(_.trim) match { + case Some(h) if h.startsWith("DirectLogin") => "DirectLogin" + case Some(h) if h.startsWith("Bearer") => "OAuth2" + case Some(h) if h.startsWith("GatewayLogin") => "GatewayLogin" + case Some(h) if h.startsWith("DAuth") => "DAuth" + case Some(h) if h.startsWith("OAuth") => "OAuth1" + case Some(_) => "Other" + case None => + if (cc.gatewayLoginRequestPayload.isDefined) "GatewayLogin" + else if (cc.directLoginToken != null && cc.directLoginToken.nonEmpty) "DirectLogin" + else if (cc.userId.isDefined) "Other" + else "Anonymous" + } + } + private def callDuration(cc: CallContextLight): Long = (cc.startTime, cc.endTime) match { case (Some(s), Some(e)) => e.getTime - s.getTime @@ -116,7 +142,8 @@ object WriteMetricUtil extends MdcLoggable { code.api.Constant.ApiInstanceId, cc.consentReferenceId.orNull, cc.certificateTrust.orNull, - cc.certificateTrustDetail.orNull + cc.certificateTrustDetail.orNull, + authType ) } catch { case NonFatal(e) => 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 47eb4c711b..9a094352ef 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 @@ -157,6 +157,7 @@ object Migration extends MdcLoggable { migrateChatRoomCreatedByAndLastMessageSender() migrateConsentReferenceIdToUuid(startedBeforeSchemifier) migrateMetricConsentReferenceId(startedBeforeSchemifier) + migrateMetricAuthType(startedBeforeSchemifier) migrateMetricCertificateTrust(startedBeforeSchemifier) dropFastFirehoseAccountsViews(startedBeforeSchemifier) alterDynamicResourceDocBodyFieldsLength() @@ -808,6 +809,18 @@ object Migration extends MdcLoggable { } } + private def migrateMetricAuthType(startedBeforeSchemifier: Boolean): Boolean = { + if(startedBeforeSchemifier == true) { + logger.warn(s"Migration.database.migrateMetricAuthType(true) cannot be run before Schemifier.") + true + } else { + val name = nameOf(migrateMetricAuthType(startedBeforeSchemifier)) + runOnce(name) { + MigrationOfMetricAuthType.migrate(name) + } + } + } + private def migrateMetricCertificateTrust(startedBeforeSchemifier: Boolean): Boolean = { if(startedBeforeSchemifier == true) { logger.warn(s"Migration.database.migrateMetricCertificateTrust(true) cannot be run before Schemifier.") diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala index 8716a35c38..278565b0a9 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala @@ -88,7 +88,7 @@ object MigrationOfActivityDashboardIndexes { * Index on resourceuser.createdbyconsentid. * * The delegation registry: consent-agent fan-down (/my/metrics, /my/banks) and - * CallContext.effectiveHumanUserId look up agent users by the consent that minted them. + * CallContext.accountableUserId look up agent users by the consent that minted them. * Unindexed this is a full scan of resourceuser on every such request, which matters on * consent-heavy instances where every consent mints a user row. */ @@ -130,7 +130,7 @@ object MigrationOfActivityDashboardIndexes { s"""Added index on resourceuser.createdbyconsentid |Executed SQL: |$executedSql - |Serves the consent-agent delegation fan-down (/my/metrics, /my/banks, effectiveHumanUserId). + |Serves the consent-agent delegation fan-down (/my/metrics, /my/banks, accountableUserId). |""".stripMargin isSuccessful = true saveLog(name, commitId, isSuccessful, startDate, endDate, comment) diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricAuthType.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricAuthType.scala new file mode 100644 index 0000000000..c79b85ccfe --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricAuthType.scala @@ -0,0 +1,74 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.metrics.MappedMetric +import net.liftweb.mapper.Schemifier + +/** + * Migration: add `auth_type VARCHAR(32)` to both the live `Metric` table and the + * `metricarchive` table — the authentication SCHEME of each call ("Consent", + * "OAuth2", "OAuth1", "DirectLogin", "GatewayLogin", "DAuth", "Anonymous", + * "Other"), never the credential itself. + * + * No backup and no backfill: the column is additive and nullable — historical rows + * legitimately predate it and stay null. No index: always queried alongside the + * indexed date range. + * + * Lift's Schemifier auto-creates the column on fresh deploys from the updated model; + * this migration handles existing deploys. Table name note as in + * MigrationOfMetricConsentReferenceId: unquoted lowercase `metric` everywhere. + */ +object MigrationOfMetricAuthType { + + def migrate(name: String): Boolean = { + DbFunction.tableExists(MappedMetric) 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 { + val addColumnMetric = if (isMssql) { + "ALTER TABLE metric ADD auth_type VARCHAR(32) NULL;" + } else { + "ALTER TABLE metric ADD COLUMN IF NOT EXISTS auth_type VARCHAR(32);" + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => addColumnMetric)).append("\n") + + val addColumnArchive = if (isMssql) { + "ALTER TABLE metricarchive ADD auth_type VARCHAR(32) NULL;" + } else { + "ALTER TABLE metricarchive ADD COLUMN IF NOT EXISTS auth_type VARCHAR(32);" + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => addColumnArchive)).append("\n") + + 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"""${MappedMetric._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } +} diff --git a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala index 38b4915de1..49666dabda 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala @@ -845,8 +845,14 @@ object Http4s200 { isValidID(bank.bankId.value) } loggedInUserId = user.userId - userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId + // Implicit owner resolves to the HUMAN: under a Consent the caller is the + // per-consent shadow, and an account held by it strands when the consent dies. + userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else cc.accountableUserId (postedOrLoggedInUser, cc2) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Accounts are held by humans - use the granting user's USER_ID.", + failCode = 400, cc = cc2)(!postedOrLoggedInUser.isConsentUser) _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) else code.util.Helper.booleanToFuture( s"${UserHasMissingRoles} $canCreateAccount or create account for self", failCode = 403, cc = Some(cc)) { @@ -1188,7 +1194,13 @@ object Http4s200 { case req @ POST -> `prefixPath` / "users" / userId / "entitlements" => EndpointHelpers.withUserAndBodyCreated[CreateEntitlementJSON, EntitlementJSON](req) { (user, body, cc) => for { - (_, cc2) <- NewStyle.function.findByUserId(userId, Some(cc)) + (targetUser, cc2) <- NewStyle.function.findByUserId(userId, Some(cc)) + // Explicit target: fail loud rather than redirect. A consent user (an agent + // identity minted by a Consent) cannot hold durable roles — grant to the + // granting human instead. + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserId USER_ID names a consent user (an agent identity minted by a Consent). Entitlements target humans - use the granting user's USER_ID.", + failCode = 400, cc = cc2)(!targetUser.isConsentUser) role <- Future { unboxFullOrFail( net.liftweb.util.Helpers.tryo { ApiRole.valueOf(body.role_name) }, diff --git a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala index f62b53c99a..c619619bb0 100644 --- a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala +++ b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala @@ -465,17 +465,20 @@ object Http4s220 { bank.swift_bic, bank.national_identifier, bank.bank_routing.scheme, bank.bank_routing.address, Some(cc) ) + // Creator grants target the HUMAN (see v6.0.0 createBank): under a Consent the + // authenticated user is a per-consent shadow, and roles granted to it are stranded. + humanUserId = cc.accountableUserId entitlements <- Future { unboxFullOrFail( - code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(user.userId), + code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(humanUserId), Some(cc), UnknownError) } _ <- Future { val bankEntitlements = entitlements.filter(_.bankId == bank.id) if (!bankEntitlements.exists(_.roleName == canCreateEntitlementAtOneBank.toString())) - code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, user.userId, canCreateEntitlementAtOneBank.toString()) + code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, humanUserId, canCreateEntitlementAtOneBank.toString(), grantedByUserId = Some(user.userId)) if (!bankEntitlements.exists(_.roleName == canReadDynamicResourceDocsAtOneBank.toString())) - code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, user.userId, canReadDynamicResourceDocsAtOneBank.toString()) + code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, humanUserId, canReadDynamicResourceDocsAtOneBank.toString(), grantedByUserId = Some(user.userId)) } } yield JSONFactory220.createBankJSON(success) } diff --git a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala index 4641e6ab44..c89a623173 100644 --- a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala +++ b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala @@ -1655,10 +1655,14 @@ object Http4s300 { _ <- code.util.Helper.booleanToFuture( if (ApiRole.valueOf(body.role_name).requiresBankId) EntitlementIsBankRole else EntitlementIsSystemRole, cc = Some(cc)) { ApiRole.valueOf(body.role_name).requiresBankId == body.bank_id.nonEmpty } + // A request for power is a request BY the human: under a Consent the caller is a + // per-consent shadow, and a request filed for it would have an admin granting to + // an identity that dies with the consent (the grant endpoint now rejects that). + requesterUserId = cc.accountableUserId _ <- code.util.Helper.booleanToFuture(EntitlementRequestAlreadyExists, cc = Some(cc)) { - EntitlementRequest.entitlementRequest.vend.getEntitlementRequest(body.bank_id, user.userId, body.role_name).isEmpty + EntitlementRequest.entitlementRequest.vend.getEntitlementRequest(body.bank_id, requesterUserId, body.role_name).isEmpty } - addedEntitlementRequest <- EntitlementRequest.entitlementRequest.vend.addEntitlementRequestFuture(body.bank_id, user.userId, body.role_name) map { + addedEntitlementRequest <- EntitlementRequest.entitlementRequest.vend.addEntitlementRequestFuture(body.bank_id, requesterUserId, body.role_name) map { x => unboxFullOrFail(x, Some(cc), EntitlementRequestCannotBeAdded) } } yield JSONFactory300.createEntitlementRequestJSON(addedEntitlementRequest) 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 168094cbc3..c6c8b4e74d 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 @@ -4301,10 +4301,16 @@ object Http4s310 { (accountBox, _) <- Connector.connector.vend.checkBankAccountExists(bank.bankId, AccountId(accountIdStr), Some(cc)) _ <- code.util.Helper.booleanToFuture(AccountIdAlreadyExists, cc = Some(cc)) { accountBox.isEmpty } loggedInUserId = user.userId - userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId + // Implicit owner resolves to the HUMAN: under a Consent the caller is the + // per-consent shadow, and an account held by it strands when the consent dies. + userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else cc.accountableUserId _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(accountIdStr) } _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bankIdStr) } (accountOwner, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Accounts are held by humans - use the granting user's USER_ID.", + failCode = 400, cc = Some(cc))(!accountOwner.isConsentUser) _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) else code.util.Helper.booleanToFuture( s"$UserHasMissingRoles $canCreateAccount or create account for self", diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index d821649e0d..7b61eba6a3 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -10137,10 +10137,16 @@ object Http4s400 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[code.api.v3_1_0.CreateAccountRequestJsonV310] } loggedInUserId = cc.userId + // Implicit owner resolves to the HUMAN: under a Consent the caller is the + // per-consent shadow, and an account held by it strands when the consent dies. userIdAccountOwner = if (createAccountJson.user_id.nonEmpty) createAccountJson.user_id - else loggedInUserId + else cc.accountableUserId (postedOrLoggedInUser, callContext) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Accounts are held by humans - use the granting user's USER_ID.", + failCode = 400, cc = Some(cc))(!postedOrLoggedInUser.isConsentUser) _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) else NewStyle.function.hasEntitlement( bankId.value, loggedInUserId, canCreateAccount, callContext, @@ -10207,10 +10213,16 @@ object Http4s400 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[SettlementAccountRequestJson] } loggedInUserId = cc.userId + // Implicit owner resolves to the HUMAN: under a Consent the caller is the + // per-consent shadow, and an account held by it strands when the consent dies. userIdAccountOwner = if (createAccountJson.user_id.nonEmpty) createAccountJson.user_id - else loggedInUserId + else cc.accountableUserId (postedOrLoggedInUser, callContext) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Accounts are held by humans - use the granting user's USER_ID.", + failCode = 400, cc = Some(cc))(!postedOrLoggedInUser.isConsentUser) _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) else NewStyle.function.hasEntitlement(bankId.value, loggedInUserId, canCreateSettlementAccountAtOneBank, callContext) initialBalanceAsString = createAccountJson.balance.amount 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 1de94f93d0..97fe6af541 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 @@ -463,18 +463,21 @@ object Http4s500 { postJson.bank_routings.getOrElse(Nil).filterNot(_.scheme == "BIC").headOption.map(_.address).getOrElse(""), Some(cc) ) - entitlements <- NewStyle.function.getEntitlementsByUserId(cc.userId, Some(cc)) + // Creator grants target the HUMAN (see v6.0.0 createBank): under a Consent the + // authenticated user is a per-consent shadow, and roles granted to it are stranded. + humanUserId = cc.accountableUserId + entitlements <- NewStyle.function.getEntitlementsByUserId(humanUserId, Some(cc)) entitlementsByBank = entitlements.filter(_.bankId == postJson.id.getOrElse("")) _ <- entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString()) match { case true => Future.successful(()) case false => Future(Entitlement.entitlement.vend.addEntitlement( - postJson.id.getOrElse(""), cc.userId, CanCreateEntitlementAtOneBank.toString(), + postJson.id.getOrElse(""), humanUserId, CanCreateEntitlementAtOneBank.toString(), grantedByUserId = Some(cc.userId))) } _ <- entitlementsByBank.exists(_.roleName == CanReadDynamicResourceDocsAtOneBank.toString()) match { case true => Future.successful(()) case false => Future(Entitlement.entitlement.vend.addEntitlement( - postJson.id.getOrElse(""), cc.userId, CanReadDynamicResourceDocsAtOneBank.toString(), + postJson.id.getOrElse(""), humanUserId, CanReadDynamicResourceDocsAtOneBank.toString(), grantedByUserId = Some(cc.userId))) } } yield JSONFactory500.createBankJSON500(success) @@ -584,10 +587,16 @@ object Http4s500 { com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[CreateAccountRequestJsonV500] } loggedInUserId = user.userId - userIdAccountOwner = createAccountJson.user_id.getOrElse(loggedInUserId) + // Implicit owner resolves to the HUMAN: under a Consent the caller is the + // per-consent shadow, and an account held by it strands when the consent dies. + userIdAccountOwner = createAccountJson.user_id.getOrElse(cc.accountableUserId) _ <- Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(accountId.value) } _ <- Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(accountId.value) } (postedOrLoggedInUser, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + _ <- Helper.booleanToFuture( + s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Accounts are held by humans - use the granting user's USER_ID.", + failCode = 400, cc = Some(cc))(!postedOrLoggedInUser.isConsentUser) _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) else Helper.booleanToFuture( s"${UserHasMissingRoles} $canCreateAccount", failCode = 403, cc = Some(cc)) { 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 96e1d695d0..009a7ae9ee 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 @@ -3195,6 +3195,12 @@ object Http4s510 { APIUtil.canGrantAccessToView(com.openbankproject.commons.model.BankIdAccountIdViewId(bankId, accountId, viewId), targetViewId, user, Some(cc)) } (targetUser, _) <- NewStyle.function.findByUserId(postJson.user_id, Some(cc)) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + // A consent user's account access comes ONLY from its Consent (materialised and + // revoked with it); access granted here would outlive nothing and confuse audits. + _ <- Helper.booleanToFuture( + s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Account access targets humans - a consent user's access comes only from its Consent.", + failCode = 400, cc = Some(cc))(!targetUser.isConsentUser) view <- if (isValidSystemViewId(targetViewId.value)) ViewNewStyle.systemView(targetViewId, Some(cc)) else ViewNewStyle.customView(targetViewId, BankIdAccountId(bankId, accountId), Some(cc)) addedView <- JSONFactory400.grantAccountAccessToUser(bankId, accountId, targetUser, view, Some(cc)) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index ff0f80087e..f7c1507713 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -199,7 +199,6 @@ object Http4s600 { else "oidc_operator_user_ids" def entitlementRequestId: Option[String] = None def groupId: Option[String] = None - def process: Option[String] = None def grantedByUserId: Option[String] = None } } @@ -491,8 +490,10 @@ object Http4s600 { else Nil ) } yield { + // Creator grants target the HUMAN (see createBank): a per-consent shadow principal + // must not end up owning the entity's admin roles. crudRoles.foreach(role => - Entitlement.entitlement.vend.addEntitlement(dynamicEntity.bankId.getOrElse(""), cc.userId, role.toString(), + Entitlement.entitlement.vend.addEntitlement(dynamicEntity.bankId.getOrElse(""), cc.accountableUserId, role.toString(), grantedByUserId = Some(cc.userId))) JSONFactory600.createMyDynamicEntitiesJson(List(result: DynamicEntityCommons)).dynamic_entities.head } @@ -869,10 +870,15 @@ object Http4s600 { postJson.bank_routings.getOrElse(Nil).filterNot(_.scheme == "BIC").headOption.map(_.address).getOrElse(""), Some(cc) ) - entitlements <- NewStyle.function.getEntitlementsByUserId(cc.userId, Some(cc)) + // Creator grant goes to the HUMAN, not the authenticated principal: under a + // Consent the principal is a per-consent shadow user, and a role granted to it + // is stranded when the consent dies (and invisible to the human's next consent). + // grantedByUserId stays the principal — the audit trail records who acted. + humanUserId = cc.accountableUserId + entitlements <- NewStyle.function.getEntitlementsByUserId(humanUserId, Some(cc)) entitlementsByBank = entitlements.filter(_.bankId == postJson.bank_id) _ = if (!entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString)) - Entitlement.entitlement.vend.addEntitlement(postJson.bank_id, cc.userId, CanCreateEntitlementAtOneBank.toString, + Entitlement.entitlement.vend.addEntitlement(postJson.bank_id, humanUserId, CanCreateEntitlementAtOneBank.toString, grantedByUserId = Some(cc.userId)) } yield JSONFactory600.createBankJSON600(success) } @@ -1943,7 +1949,14 @@ object Http4s600 { postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostGroupMembershipJsonV600", 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[JSONFactory600.PostGroupMembershipJsonV600] } - _ <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + (targetUser, _) <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + // Group membership is for humans. A consent user (an agent identity minted by a + // Consent) cannot hold durable roles — addEntitlement would redirect the grant to + // its granting human anyway, and removal via the consent user's id would then find + // nothing. Reject explicitly so the caller targets the human on purpose. + _ <- Helper.booleanToFuture( + s"$InvalidUserId USER_ID names a consent user (an agent identity minted by a Consent). Group membership targets humans - use the granting user's USER_ID.", + 400, Some(cc))(!targetUser.isConsentUser) group <- Future(code.group.GroupTrait.group.vend.getGroup(postJson.group_id)) .map(unboxFullOrFail(_, Some(cc), s"$UnknownError Group not found", 404)) _ <- groupRoleCheck(group.bankId, user.userId, canAddUserToGroupAtOneBank, canAddUserToGroupAtAllBanks, cc) @@ -1955,9 +1968,12 @@ object Http4s600 { ent.roleName == roleName && ent.bankId == group.bankId.getOrElse("") }) if (!alreadyHas) { + // createdByProcess carries the provenance (was left at "manual", making + // group-born rows read as hand-granted before the duplicate `process` + // column was retired). Entitlement.entitlement.vend.addEntitlement( - group.bankId.getOrElse(""), userIdStr, roleName, "manual", - Some(user.userId), Some(postJson.group_id), Some("GROUP_MEMBERSHIP")) + group.bankId.getOrElse(""), userIdStr, roleName, Constant.group_membership, + Some(user.userId), Some(postJson.group_id)) (roleName, true) } else (roleName, false) } @@ -1983,8 +1999,10 @@ object Http4s600 { .map(unboxFullOrFail(_, Some(cc), s"$UnknownError Group not found", 404)) _ <- groupRoleCheck(group.bankId, user.userId, canRemoveUserFromGroupAtOneBank, canRemoveUserFromGroupAtAllBanks, cc) entitlements <- Future(Entitlement.entitlement.vend.getEntitlementsByUserId(userIdStr)) + // group_id alone identifies group-born rows (only group grants set it) and holds + // for legacy rows too; the old `process == GROUP_MEMBERSHIP` conjunct was redundant. groupEntitlements = entitlements.toOption.getOrElse(List.empty).filter(e => - e.groupId == Some(groupId) && e.process == Some("GROUP_MEMBERSHIP")) + e.groupId == Some(groupId)) _ <- Future.sequence(groupEntitlements.map(e => Future(Entitlement.entitlement.vend.deleteEntitlement(Full(e))))) } yield "" @@ -2485,7 +2503,13 @@ object Http4s600 { _ <- Helper.booleanToFuture(BusinessJustificationRequired, cc = Some(cc)) { postJson.business_justification.trim.nonEmpty } - (_, _) <- NewStyle.function.findByUserId(postJson.target_user_id, Some(cc)) + (targetUser, _) <- NewStyle.function.findByUserId(postJson.target_user_id, Some(cc)) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + // Reject at request creation so no approver ever sees a request that the grant + // step would refuse anyway. + _ <- Helper.booleanToFuture( + s"$InvalidUserId target_user_id names a consent user (an agent identity minted by a Consent). Account access targets humans - a consent user's access comes only from its Consent.", + failCode = 400, cc = Some(cc))(!targetUser.isConsentUser) _ <- Helper.booleanToFuture(AccountAccessRequestAlreadyExists, 409, Some(cc)) { code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend .getByUserAccountView(postJson.target_user_id, bankIdStr, accountIdStr, postJson.view_id) @@ -2535,6 +2559,11 @@ object Http4s600 { u.userId != request.requestorUserId } (targetUser, _) <- NewStyle.function.findByUserId(request.targetUserId, Some(cc)) + // Belt and braces with the creation-side guard: a request stored before that guard + // existed (or written another way) must still not be granted to a consent user. + _ <- Helper.booleanToFuture( + s"$InvalidUserId The request's target user is a consent user (an agent identity minted by a Consent). Account access targets humans - a consent user's access comes only from its Consent.", + failCode = 400, cc = Some(cc))(!targetUser.isConsentUser) // Win the INITIATED -> APPROVED transition BEFORE granting view access. The provider's // conditional UPDATE makes this request the single actioner; the loser of a concurrent // approve/reject race gets a 400 here with NO side effect. Granting first would leave @@ -4453,7 +4482,8 @@ object Http4s600 { for { (_, _) <- NewStyle.function.findByUserId(userId, Some(cc)) entitlements <- Future(code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(userId)) - groupEntitlements = entitlements.toOption.getOrElse(List.empty).filter(_.process == Some("GROUP_MEMBERSHIP")) + // group_id alone identifies group-born rows (see removeUserFromGroup). + groupEntitlements = entitlements.toOption.getOrElse(List.empty).filter(_.groupId.isDefined) groupIds = groupEntitlements.flatMap(_.groupId).distinct _ <- Future.sequence { groupIds.flatMap { gid => @@ -5607,7 +5637,7 @@ object Http4s600 { entitlement_id = ent.entitlementId, role_name = ent.roleName, bank_id = ent.bankId, user_id = ent.userId, username = userBox.map(_.name).getOrElse(""), - group_id = ent.groupId, process = ent.process) + group_id = ent.groupId, created_by_process = ent.createdByProcess) } }) } yield GroupEntitlementsJsonV600(withUsernames) @@ -9358,7 +9388,6 @@ object Http4s600 { | |Only removes entitlements with: |- group_id matching GROUP_ID - |- process = "GROUP_MEMBERSHIP" | |Requires either: |- CanRemoveUserFromGroupAtAllBanks (for any group) @@ -12527,7 +12556,7 @@ object Http4s600 { "Get User's Group Memberships", s"""Get all groups a user is a member of. | - |Returns groups where the user has entitlements with process = "GROUP_MEMBERSHIP". + |Returns groups where the user has entitlements carrying a group_id. | |The response includes: |- list_of_entitlements: entitlements the user currently has from this group membership @@ -13838,7 +13867,7 @@ object Http4s600 { user_id = "user-id-123", username = "susan.uk.29@example.com", group_id = Some("group-id-123"), - process = Some("GROUP_MEMBERSHIP") + created_by_process = "GROUP_MEMBERSHIP" ) ) ), diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index a4961c34c0..0d126d57bf 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -472,6 +472,10 @@ case class MetricJsonV600( operation_id: String, api_instance_id: String, consent_reference_id: Option[String], + // Authentication scheme of the call: "Consent", "OAuth2", "OAuth1", "DirectLogin", + // "GatewayLogin", "DAuth", "Anonymous", "Other". Absent on rows written before the + // auth_type column existed. + auth_type: Option[String], // How the caller's certificate was established: "direct", "forwarded" or "none"; // absent when the request carried no certificate material. See PeerTrust.Resolution. certificate_trust: Option[String], @@ -1745,6 +1749,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { operation_id = operationId, api_instance_id = metric.getApiInstanceId(), consent_reference_id = Option(metric.getConsentReferenceId()).filter(_.nonEmpty), + auth_type = Option(metric.getAuthType()).filter(_.nonEmpty), certificate_trust = Option(metric.getCertificateTrust()).filter(_.nonEmpty), certificate_trust_detail = Option(metric.getCertificateTrustDetail()).filter(_.nonEmpty) ) @@ -2061,7 +2066,9 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { user_id: String, username: String, group_id: Option[String], - process: Option[String] + // The row's stored provenance, verbatim: "GROUP_MEMBERSHIP" for rows granted since + // provenance moved to created_by_process; legacy group rows show "manual". + created_by_process: String ) case class GroupEntitlementsJsonV600( diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 55f4c40f0b..f0b393193b 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -287,12 +287,12 @@ object Http4s700 { // Response shapes reuse the v6 bank JSON (BankJson600 / BanksJsonV600). // ─── Delegation fan-down for /my/banks ─────────────────────────────────── - // Resolving UP (agent caller → the granting human) is cc.effectiveHumanUserId. + // Resolving UP (agent caller → the granting human) is cc.accountableUserId. // This is the fan DOWN: the human plus every agent user minted from any Consent the // human granted — i.e. all user ids whose creations belong to that human. Match the // result against CreatedByUserId. Reads only server-written columns // (MappedConsent.mUserId, ResourceUser.CreatedByConsentId); the input must be an - // already-resolved human id (cc.effectiveHumanUserId), never a raw caller value. + // already-resolved human id (cc.accountableUserId), never a raw caller value. private def humanAndAgentUserIds(humanUserId: String): List[String] = { val consentIds = Consents.consentProvider.vend.getConsentsByUser(humanUserId) @@ -336,7 +336,7 @@ object Http4s700 { // Quota binds to the human: banks created by the human directly or by any // of their consent-agents count toward the same limit — otherwise every // new consent would arrive with a fresh quota. - val creatorUserIds = humanAndAgentUserIds(cc.effectiveHumanUserId) + val creatorUserIds = humanAndAgentUserIds(cc.accountableUserId) MappedBank.count(ByList(MappedBank.CreatedByUserId, creatorUserIds)) } _ <- Helper.booleanToFuture(SelfServiceBankLimitReached, failCode = 403, cc = Some(cc)) { @@ -356,8 +356,11 @@ object Http4s700 { "", "", "", "", "", "", Some(cc) ) + // Creator grant targets the HUMAN (see v6.0.0 createBank): under a Consent the + // authenticated user is a per-consent shadow, and roles granted to it are stranded. _ <- Future(Entitlement.entitlement.vend.addEntitlement( - generatedName.bankId, cc.userId, canCreateEntitlementAtOneBank.toString())) + generatedName.bankId, cc.accountableUserId, canCreateEntitlementAtOneBank.toString(), + grantedByUserId = Some(cc.userId))) } yield JSONFactory600.createBankJSON600(bank) } } @@ -414,7 +417,7 @@ object Http4s700 { EndpointHelpers.withUser(req) { (user, cc) => for { banksCreatedByUser <- Future { - val creatorUserIds = humanAndAgentUserIds(cc.effectiveHumanUserId) + val creatorUserIds = humanAndAgentUserIds(cc.accountableUserId) MappedBank.findAll(ByList(MappedBank.CreatedByUserId, creatorUserIds)) } } yield JSONFactory600.createBanksJsonV600(banksCreatedByUser) @@ -484,7 +487,13 @@ object Http4s700 { case req @ POST -> `prefixPath` / "users" / userId / "entitlements" => EndpointHelpers.withUserAndBodyCreated[CreateEntitlementJSON, AnyRef](req) { (user, body, cc) => for { - (_, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + (targetUser, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + // Explicit target: fail loud rather than redirect. A consent user (an agent + // identity minted by a Consent) cannot hold durable roles — grant to the + // granting human instead. + _ <- Helper.booleanToFuture( + s"$InvalidUserId USER_ID names a consent user (an agent identity minted by a Consent). Entitlements target humans - use the granting user's USER_ID.", + failCode = 400, cc = Some(cc))(!targetUser.isConsentUser) role <- NewStyle.function.tryons( s"$InvalidJsonFormat Unknown role: ${body.role_name}. Possible roles: ${ApiRole.availableRoles.sorted.mkString(", ")}", 400, Some(cc)) { ApiRole.valueOf(body.role_name) } @@ -933,7 +942,6 @@ object Http4s700 { else "oidc_operator_user_ids" def entitlementRequestId: Option[String] = None def groupId: Option[String] = None - def process: Option[String] = None def grantedByUserId: Option[String] = None } } @@ -1140,7 +1148,7 @@ object Http4s700 { // human, then fan down — both via server-written columns only. (metrics, _) <- APIMetrics.getMetricsFromHttpParams( httpParams, cc.callContext, - lockedUserIds = Some(humanAndAgentUserIds(cc.effectiveHumanUserId))) + lockedUserIds = Some(humanAndAgentUserIds(cc.accountableUserId))) } yield JSONFactory600.createMetricsJsonV600(metrics) } } @@ -4197,8 +4205,14 @@ object Http4s700 { case None => Future.successful(AccountId(APIUtil.generateUUID())) } // CanCreateAccount is enforced by ResourceDocMiddleware from the doc. - ownerId = body.user_id.filter(_.trim.nonEmpty).getOrElse(user.userId) + // The implicit owner is the HUMAN: under a Consent the caller (user.userId) is the + // per-consent shadow, and an account held by it strands when the consent dies. + ownerId = body.user_id.filter(_.trim.nonEmpty).getOrElse(cc.accountableUserId) (owner, _) <- NewStyle.function.findByUserId(ownerId, Some(cc)) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + _ <- Helper.booleanToFuture( + s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Accounts are held by humans - use the granting user's USER_ID.", + failCode = 400, cc = Some(cc))(!owner.isConsentUser) initialBalance <- NewStyle.function.tryons(InvalidAccountInitialBalance, 400, Some(cc)) { BigDecimal(body.balance.amount) } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index 9d05ca346c..fae816abb6 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -1526,7 +1526,13 @@ object LocalMappedConnectorInternal extends MdcLoggable { accountRoutings = Nil, callContext = callContext ) - _ <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, newAccountId, cc.get.user.head, callContext) + // Holder is the HUMAN: under a Consent cc.user is the per-consent shadow, and a + // holding account held by it would strand when the consent dies. For non-consent + // callers accountableUserId is the caller, so this is a no-op for them. + holdingAccountHolder = cc.flatMap(c => + code.users.Users.users.vend.getUserByUserId(c.accountableUserId).toOption + ).getOrElse(cc.get.user.head) + _ <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, newAccountId, holdingAccountHolder, callContext) // create attribute on holding account to link to releaser account _ <- NewStyle.function.createOrUpdateAccountAttribute( bankId = bankId, diff --git a/obp-api/src/main/scala/code/entitlement/Entilement.scala b/obp-api/src/main/scala/code/entitlement/Entilement.scala index 211d227f0a..9fbe78ae4d 100644 --- a/obp-api/src/main/scala/code/entitlement/Entilement.scala +++ b/obp-api/src/main/scala/code/entitlement/Entilement.scala @@ -44,8 +44,7 @@ trait EntitlementProvider { // createdByProcess carries the provenance. Authorization is the // calling endpoint's responsibility, not this method's. grantedByUserId: Option[String] = None, - groupId: Option[String] = None, - process: Option[String] = None + groupId: Option[String] = None ): Box[Entitlement] def deleteDynamicEntityEntitlement( entityName: String, @@ -62,7 +61,6 @@ trait Entitlement { def createdByProcess: String def entitlementRequestId: Option[String] def groupId: Option[String] - def process: Option[String] /** user_id of the granter, when the grant was made by a person (directly * or as a self-grant). None for system-process grants and virtual diff --git a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala index da3c09b385..7d032cb521 100644 --- a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala +++ b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala @@ -6,6 +6,7 @@ import code.api.util.ApiRole.{ CanCreateEntitlementAtOneBank } import code.api.util.{ErrorMessages, NotificationUtil} +import code.util.Helper.MdcLoggable import code.util.{MappedUUID, UUIDString} import net.liftweb.common.{Box, Failure, Full} import net.liftweb.mapper._ @@ -15,7 +16,7 @@ import scala.concurrent.Future import com.openbankproject.commons.ExecutionContext.Implicits.global import net.liftweb.common -object MappedEntitlementsProvider extends EntitlementProvider { +object MappedEntitlementsProvider extends EntitlementProvider with MdcLoggable { override def getEntitlement( bankId: String, userId: String, @@ -163,32 +164,57 @@ object MappedEntitlementsProvider extends EntitlementProvider { roleName: String, createdByProcess: String = "manual", grantedByUserId: Option[String] = None, - groupId: Option[String] = None, - process: Option[String] = None + groupId: Option[String] = None ): Box[Entitlement] = { // grantedByUserId is audit metadata, stored as-is: authorization is the // calling endpoint's responsibility. (Until 2026-08-09 an unused // grantorUserId parameter gated on the grantor's granting roles here — // no caller ever passed it, and the check ignored super admins, whose // granting rights are virtual and have no rows to find.) + + // On-behalf-of guard: a consent user (the per-consent principal a Consent-JWT + // authenticates as; its ResourceUser row carries CreatedByConsentId) must not + // accumulate durable roles — they strand when the consent dies, invisible to the + // human's next consent (see the simon.bank creator-grant incident, 2026-08-31). + // Any grant targeting one is redirected to the consent's granting human. The one + // legitimate writer of consent-user rows is the consent engine copying the + // consent's own scope, which tags itself Constant.consent_user and is exempt. + val targetUserId = + if (createdByProcess == code.api.Constant.consent_user) userId + else { + val grantingHumanUserId = for { + resourceUser <- code.model.dataAccess.ResourceUser.find( + By(code.model.dataAccess.ResourceUser.userId_, userId)) + consentId <- Full(resourceUser.CreatedByConsentId.get) + .filter(id => id != null && id.nonEmpty) + consent <- code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId) + humanUserId <- Full(consent.userId).filter(id => id != null && id.nonEmpty) + } yield humanUserId + grantingHumanUserId match { + case Full(humanUserId) => + logger.warn(s"addEntitlement: target user $userId is a consent user; granting role '$roleName' (bankId '$bankId', createdByProcess '$createdByProcess') to its granting human $humanUserId instead") + humanUserId + case _ => userId + } + } + def addEntitlementToUser(): Box[MappedEntitlement] = { val entitlement = MappedEntitlement.create .mBankId(bankId) - .mUserId(userId) + .mUserId(targetUserId) .mRoleName(roleName) .mCreatedByProcess(createdByProcess) grantedByUserId.foreach(g => entitlement.mGrantedByUserId(g)) groupId.foreach(gid => entitlement.mGroupId(gid)) - process.foreach(p => entitlement.mProcess(p)) tryo(entitlement.saveMe()) match { case Full(saved) => - NotificationUtil.sendEmailRegardingAssignedRole(userId, saved) + NotificationUtil.sendEmailRegardingAssignedRole(targetUserId, saved) Full(saved) case Failure(_, _, _) => // UniqueIndex(mBankId, mUserId, mRoleName) violated by concurrent grant — return the committed row MappedEntitlement.find( By(MappedEntitlement.mBankId, bankId), - By(MappedEntitlement.mUserId, userId), + By(MappedEntitlement.mUserId, targetUserId), By(MappedEntitlement.mRoleName, roleName) ) case other => other @@ -217,10 +243,10 @@ class MappedEntitlement override def defaultValue = "" } - object mProcess extends MappedString(this, 255) { - override def dbColumnName = "process" - override def defaultValue = "" - } + // The legacy "process" DB column (a duplicate of createdByProcess written only by the + // Groups feature) is no longer mapped: group rows are identified by group_id, and + // provenance lives in createdByProcess. The column itself can be dropped from the DB + // whenever convenient. object entitlement_request_id extends MappedUUID(this) { override def dbColumnName = "entitlement_request_id" @@ -243,10 +269,6 @@ class MappedEntitlement val gid = mGroupId.get if (gid == null || gid.isEmpty) None else Some(gid) } - override def process: Option[String] = { - val p = mProcess.get - if (p == null || p.isEmpty) None else Some(p) - } override def grantedByUserId: Option[String] = { val g = mGrantedByUserId.get if (g == null || g.isEmpty) None else Some(g) diff --git a/obp-api/src/main/scala/code/metrics/APIMetrics.scala b/obp-api/src/main/scala/code/metrics/APIMetrics.scala index c8b26ae5e8..c3dfa8e784 100644 --- a/obp-api/src/main/scala/code/metrics/APIMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/APIMetrics.scala @@ -101,7 +101,8 @@ trait APIMetrics { apiInstanceId: String, consentReferenceId: String, certificateTrust: String, - certificateTrustDetail: String): Unit + certificateTrustDetail: String, + authType: String): Unit def saveMetricsArchive(primaryKey: Long, userId: String, @@ -123,7 +124,8 @@ trait APIMetrics { apiInstanceId: String, consentReferenceId: String, certificateTrust: String, - certificateTrustDetail: String + certificateTrustDetail: String, + authType: String ): Boolean // //TODO: ordering of list? should this be by date? currently not enforced @@ -183,6 +185,9 @@ trait APIMetric { def getConsentReferenceId(): String def getCertificateTrust(): String def getCertificateTrustDetail(): String + // Authentication scheme of the call — "Consent", "OAuth2", "OAuth1", "DirectLogin", + // "GatewayLogin", "DAuth", "Anonymous" or "Other". Scheme only, never credentials. + def getAuthType(): String } @@ -210,7 +215,7 @@ case class AggregateMetrics( minResponseTime: Double, maxResponseTime: Double, // Distinct humans behind the calls: consent-borne rows are attributed to the granting - // (on-behalf-of) user via the consent table, mirroring CallContext.effectiveHumanUserId. + // (on-behalf-of) user via the consent table, mirroring CallContext.accountableUserId. distinctUserCount: Int, distinctConsumerCount: Int, // Calls that arrived under a consent (metric.consent_reference_id not null), and how many diff --git a/obp-api/src/main/scala/code/metrics/ElasticsearchMetrics.scala b/obp-api/src/main/scala/code/metrics/ElasticsearchMetrics.scala index fb80d918d3..925cb6abed 100644 --- a/obp-api/src/main/scala/code/metrics/ElasticsearchMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/ElasticsearchMetrics.scala @@ -15,7 +15,8 @@ object ElasticsearchMetrics extends APIMetrics { override def saveMetric(userId: String, url: String, date: Date, duration: Long, userName: String, appName: String, developerEmail: String, consumerId: String, implementedByPartialFunction: String, implementedInVersion: String, verb: String, httpCode: Option[Int], correlationId: String, responseBody: String, sourceIp: String, targetIp: String, apiInstanceId: String, consentReferenceId: String, - certificateTrust: String, certificateTrustDetail: String): Unit = { + certificateTrust: String, certificateTrustDetail: String, + authType: String): Unit = { if (APIUtil.getPropsAsBoolValue("allow_elasticsearch", false) && APIUtil.getPropsAsBoolValue("allow_elasticsearch_metrics", false) ) { //TODO ,need to be fixed now add more parameters es.indexMetric(userId, url, date, duration, userName, appName, developerEmail, correlationId, apiInstanceId) @@ -28,7 +29,8 @@ object ElasticsearchMetrics extends APIMetrics { apiInstanceId: String, consentReferenceId: String, certificateTrust: String, - certificateTrustDetail: String): Boolean = ??? + certificateTrustDetail: String, + authType: String): Boolean = ??? // override def getAllGroupedByUserId(): Map[String, List[APIMetric]] = { // //TODO: replace the following with valid ES query diff --git a/obp-api/src/main/scala/code/metrics/MappedMetrics.scala b/obp-api/src/main/scala/code/metrics/MappedMetrics.scala index e890e5e2e6..e6ad043619 100644 --- a/obp-api/src/main/scala/code/metrics/MappedMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/MappedMetrics.scala @@ -114,7 +114,8 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ override def saveMetric(userId: String, url: String, date: Date, duration: Long, userName: String, appName: String, developerEmail: String, consumerId: String, implementedByPartialFunction: String, implementedInVersion: String, verb: String, httpCode: Option[Int], correlationId: String, responseBody: String, sourceIp: String, targetIp: String, apiInstanceId: String, consentReferenceId: String, - certificateTrust: String, certificateTrustDetail: String): Unit = { + certificateTrust: String, certificateTrustDetail: String, + authType: String): Unit = { // A correlation id is expected on every metric. Rows without one cannot be moved // to the archive later (its correlationId column requires a UUID), so flag it at // write time where the source of the missing id can actually be traced. @@ -142,7 +143,8 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ apiInstanceId = apiInstanceId, consentReferenceId = consentReferenceId, certificateTrust = certificateTrust, - certificateTrustDetail = certificateTrustDetail + certificateTrustDetail = certificateTrustDetail, + authType = authType ) ) } @@ -153,7 +155,8 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ verb: String, httpCode: Option[Int], correlationId: String, responseBody: String, sourceIp: String, targetIp: String, apiInstanceId: String, consentReferenceId: String, - certificateTrust: String, certificateTrustDetail: String): Boolean = { + certificateTrust: String, certificateTrustDetail: String, + authType: String): Boolean = { // Fix: dedup by the source metric's primary key stored in `metricId`, NOT by the // archive's own auto-increment `id`. The two are unrelated id-spaces; matching on // `id` overwrites an unrelated archived row once the archive's id sequence grows @@ -181,6 +184,7 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ .consentReferenceId(consentReferenceId) .certificateTrust(certificateTrust) .certificateTrustDetail(certificateTrustDetail) + .authType(authType) httpCode match { case Some(code) => metric.httpCode(code) @@ -431,7 +435,7 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ // The LEFT JOIN attributes consent-borne calls to the granting (on-behalf-of) human: // metric.userid records the AUTHENTICATED principal, which under a consent is the // consent's own shadow user. COALESCE(consent.muserid, metric.userid) resolves such rows - // to the granting human at read time, mirroring CallContext.effectiveHumanUserId. (Rows + // to the granting human at read time, mirroring CallContext.accountableUserId. (Rows // written 2026-08 only, while toLight briefly recorded the human, resolve identically.) // The consent side of the join is unique-indexed on consent_reference_id, so the join // cannot fan out rows. @@ -827,6 +831,13 @@ class MappedMetric extends APIMetric with LongKeyedMapper[MappedMetric] with IdP } // The specifics behind certificateTrust (PeerTrust.Resolution.detail): the forwarding proxy's // canonical subject DN for "forwarded", the rejection reason for "none". Null for "direct". + // Authentication scheme of the call (scheme only, never credentials): "Consent", + // "OAuth2", "OAuth1", "DirectLogin", "GatewayLogin", "DAuth", "Anonymous", "Other". + // Null on rows written before the column existed. + object authType extends MappedString(this, 32) { + override def dbColumnName = "auth_type" + override def defaultValue = null + } object certificateTrustDetail extends MappedString(this, 255) { override def dbColumnName = "certificate_trust_detail" override def defaultValue = null @@ -853,6 +864,7 @@ class MappedMetric extends APIMetric with LongKeyedMapper[MappedMetric] with IdP override def getConsentReferenceId(): String = consentReferenceId.get override def getCertificateTrust(): String = certificateTrust.get override def getCertificateTrustDetail(): String = certificateTrustDetail.get + override def getAuthType(): String = authType.get } object MappedMetric extends MappedMetric with LongKeyedMetaMapper[MappedMetric] { @@ -921,6 +933,13 @@ class MetricArchive extends APIMetric with LongKeyedMapper[MetricArchive] with I override def dbColumnName = "certificate_trust" override def defaultValue = null } + // Authentication scheme of the call (scheme only, never credentials): "Consent", + // "OAuth2", "OAuth1", "DirectLogin", "GatewayLogin", "DAuth", "Anonymous", "Other". + // Null on rows written before the column existed. + object authType extends MappedString(this, 32) { + override def dbColumnName = "auth_type" + override def defaultValue = null + } object certificateTrustDetail extends MappedString(this, 255) { override def dbColumnName = "certificate_trust_detail" override def defaultValue = null @@ -948,6 +967,7 @@ class MetricArchive extends APIMetric with LongKeyedMapper[MetricArchive] with I override def getConsentReferenceId(): String = consentReferenceId.get override def getCertificateTrust(): String = certificateTrust.get override def getCertificateTrustDetail(): String = certificateTrustDetail.get + override def getAuthType(): String = authType.get } object MetricArchive extends MetricArchive with LongKeyedMetaMapper[MetricArchive] { override def dbIndexes = diff --git a/obp-api/src/main/scala/code/metrics/MetricBatchWriter.scala b/obp-api/src/main/scala/code/metrics/MetricBatchWriter.scala index d1a05d5ba2..36107eafe1 100644 --- a/obp-api/src/main/scala/code/metrics/MetricBatchWriter.scala +++ b/obp-api/src/main/scala/code/metrics/MetricBatchWriter.scala @@ -43,7 +43,8 @@ object MetricBatchWriter extends MdcLoggable { apiInstanceId: String, consentReferenceId: String, certificateTrust: String, - certificateTrustDetail: String + certificateTrustDetail: String, + authType: String ) private val queue = new ConcurrentLinkedQueue[MetricRow]() @@ -106,8 +107,8 @@ object MetricBatchWriter extends MdcLoggable { developeremail, consumerid, implementedbypartialfunction, implementedinversion, verb, httpcode, correlationid, responsebody, sourceip, targetip, apiinstanceid, consent_reference_id, - certificate_trust, certificate_trust_detail - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + certificate_trust, certificate_trust_detail, auth_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ // Use Option[String] so Doobie handles nullable fields via Put[Option[String]] @@ -117,7 +118,7 @@ object MetricBatchWriter extends MdcLoggable { Option[String], Option[String], Option[String], Option[String], Option[String], Int, Option[String], Option[String], Option[String], Option[String], Option[String], Option[String], - Option[String], Option[String]) + Option[String], Option[String], Option[String]) ](insertSql) val values = rows.map { r => @@ -128,7 +129,8 @@ object MetricBatchWriter extends MdcLoggable { Option(r.implementedInVersion), Option(r.verb), r.httpCode, Option(r.correlationId), Option(r.responseBody), Option(r.sourceIp), Option(r.targetIp), Option(r.apiInstanceId), Option(r.consentReferenceId), - Option(r.certificateTrust), Option(r.certificateTrustDetail) + Option(r.certificateTrust), Option(r.certificateTrustDetail), + Option(r.authType) ) } diff --git a/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala b/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala index 6697c04976..bb305ec7d2 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala @@ -149,7 +149,7 @@ class ResourceUser extends LongKeyedMapper[ResourceUser] with User with ManyToMa object ResourceUser extends ResourceUser with LongKeyedMetaMapper[ResourceUser]{ // userId_ is deliberately NOT declared here: MigrationOfUserIdIndexes creates a stronger // UNIQUE index on it (resourceuser_userid_unique). CreatedByConsentId is the delegation - // registry — consent-agent fan-down (/my/metrics, /my/banks) and effectiveHumanUserId join + // registry — consent-agent fan-down (/my/metrics, /my/banks) and accountableUserId join // through it; nothing else indexes it, which matters on consent-heavy instances where every // consent mints a user row. override def dbIndexes = UniqueIndex(provider_, providerId) :: Index(CreatedByConsentId) :: super.dbIndexes diff --git a/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala b/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala index 4633114f1b..e0faac79aa 100644 --- a/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala @@ -227,7 +227,8 @@ object MetricsArchiveScheduler extends MdcLoggable { i.getApiInstanceId(), i.getConsentReferenceId(), i.getCertificateTrust(), - i.getCertificateTrustDetail() + i.getCertificateTrustDetail(), + i.getAuthType() ) } diff --git a/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala b/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala index 3171733e8b..571480a9a7 100644 --- a/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala +++ b/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala @@ -14,7 +14,7 @@ import org.scalatest.Tag * - LiftUsers.createResourceUser field assignments — pins the 2021 copy-paste bug where * the createdByUserInvitationId None branch wiped CreatedByConsentId (the consent → * agent linkage every delegation query joins through). - * - CallContext.effectiveHumanUserId — resolve-up from the authenticated caller (human + * - CallContext.accountableUserId — resolve-up from the authenticated caller (human * or consent-minted agent) to the human the request is really about, including the * branch an HTTP test cannot reach (the agent as the caller). */ @@ -71,23 +71,23 @@ class AgentDelegationTest extends ServerSetup { } } - feature("CallContext.effectiveHumanUserId resolves the caller to the human the request is about") { + feature("CallContext.accountableUserId resolves the caller to the human the request is about") { scenario("a plain human resolves to themselves", AgentDelegationTag) { val human = createUser() - CallContext(user = Full(human)).effectiveHumanUserId shouldBe human.userId + CallContext(user = Full(human)).accountableUserId shouldBe human.userId } scenario("a consent-minted agent resolves to the granting human", AgentDelegationTag) { val human = createUser() val consent = MappedConsent.create.mUserId(human.userId).saveMe() val agent = createUser(createdByConsentId = Some(consent.consentId)) - CallContext(user = Full(agent)).effectiveHumanUserId shouldBe human.userId + CallContext(user = Full(agent)).accountableUserId shouldBe human.userId } scenario("an agent with a dangling consent id falls back to itself (fails closed)", AgentDelegationTag) { val agent = createUser(createdByConsentId = Some(generateUUID())) - CallContext(user = Full(agent)).effectiveHumanUserId shouldBe agent.userId + CallContext(user = Full(agent)).accountableUserId shouldBe agent.userId } scenario("a populated consenter box wins over the DB chain", AgentDelegationTag) { @@ -96,7 +96,7 @@ class AgentDelegationTest extends ServerSetup { val agent = createUser(createdByConsentId = Some(consent.consentId)) val consenterHuman = createUser() CallContext(user = Full(agent), consenter = Full(consenterHuman)) - .effectiveHumanUserId shouldBe consenterHuman.userId + .accountableUserId shouldBe consenterHuman.userId } scenario("onBehalfOfUser wins over consenter", AgentDelegationTag) { @@ -107,7 +107,7 @@ class AgentDelegationTest extends ServerSetup { user = Full(agent), consenter = Full(consenterHuman), onBehalfOfUser = Full(explicitHuman) - ).effectiveHumanUserId shouldBe explicitHuman.userId + ).accountableUserId shouldBe explicitHuman.userId } } } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/MetricAuthTypeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/MetricAuthTypeTest.scala new file mode 100644 index 0000000000..1a79eb22f6 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/MetricAuthTypeTest.scala @@ -0,0 +1,60 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanReadMetrics +import code.entitlement.Entitlement +import code.metrics.MetricBatchWriter +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +/** + * Tests the auth_type field on metric rows (v6.0.0): the authentication SCHEME of each + * call — never the credential. Uses the url-filter isolation pattern (write_metrics is + * JVM-wide; other suites also land rows). + * + * The harness's <@ (user1) authenticates via DirectLogin, so this suite's rows must + * carry auth_type "DirectLogin"; the invariant "Consent only with a consent reference" + * is asserted over whatever rows the filter returns. + */ +class MetricAuthTypeTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag("getMetrics") + + private val KnownAuthTypes = + Set("Consent", "OAuth2", "OAuth1", "DirectLogin", "GatewayLogin", "DAuth", "Anonymous", "Other") + + feature(s"test auth_type on metric rows version $VersionOfApi") { + scenario("Rows carry the scheme that authenticated them", ApiEndpoint1, VersionOfApi) { + setPropsValues("write_metrics" -> "true") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanReadMetrics.toString) + + val trafficUrl = "/obp/v5.1.0/banks" + val requestBanks = (v5_1_0_Request / "banks").GET <@ (user1) + (1 to 3).foreach(_ => makeGetRequest(requestBanks)) + + MetricBatchWriter.flush() + + When("We fetch the metric rows for the traffic url") + val request = (v6_0_0_Request / "management" / "metrics").GET <@ (user1) < + val authType = (m \ "auth_type").extractOpt[String] + withClue(s"row: $m ") { + authType.isDefined shouldBe true + KnownAuthTypes should contain(authType.get) + // Scheme/reference consistency: "Consent" implies a consent reference and vice versa. + val consentRef = (m \ "consent_reference_id").extractOpt[String] + (authType.get == "Consent") shouldBe consentRef.isDefined + } + } + metrics.map(m => (m \ "auth_type").extractOpt[String]).flatten.toSet shouldBe Set("DirectLogin") + } + } +} diff --git a/obp-api/src/test/scala/code/metrics/MetricsTest.scala b/obp-api/src/test/scala/code/metrics/MetricsTest.scala index 729abd8cdd..28f468ed59 100644 --- a/obp-api/src/test/scala/code/metrics/MetricsTest.scala +++ b/obp-api/src/test/scala/code/metrics/MetricsTest.scala @@ -65,7 +65,7 @@ class MetricsTest extends ServerSetup with WipeMetrics { scenario("We save a new API metric") { metrics.saveMetric(testUserId,testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) MetricBatchWriter.flush() val byUrl = metrics.getAllMetrics(List(OBPLimit(limit))).groupBy(_.getUrl()) @@ -83,16 +83,16 @@ class MetricsTest extends ServerSetup with WipeMetrics { scenario("Group all metrics by url") { metrics.saveMetric(testUserId, testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) metrics.saveMetric(testUserId, testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) metrics.saveMetric(testUserId, testUrl1, day2, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) metrics.saveMetric(testUserId, testUrl2, day2, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) MetricBatchWriter.flush() val byUrl = metrics.getAllMetrics(List(OBPLimit(limit1))).groupBy(_.getUrl()) @@ -113,16 +113,16 @@ class MetricsTest extends ServerSetup with WipeMetrics { scenario("Group all metrics by day") { metrics.saveMetric(testUserId, testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) metrics.saveMetric(testUserId, testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) metrics.saveMetric(testUserId, testUrl1, day2, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) metrics.saveMetric(testUserId, testUrl2, day2, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null, null) MetricBatchWriter.flush() val byDay = metrics.getAllMetrics(List(OBPLimit(limit2))).groupBy(APIMetrics.getMetricDay) From da5a64be9cb27168384e98f596dc23500e056b0a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 1 Sep 2026 08:49:07 +0200 Subject: [PATCH 2/2] Fix to Account Application v3.1.0 --- .../scala/code/api/v3_1_0/Http4s310.scala | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) 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 c6c8b4e74d..12ec0a2e1e 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 @@ -3063,8 +3063,9 @@ object Http4s310 { ) // ─── updateAccountApplicationStatus (PUT) ──────────────────────────────── - // Side effect: when status == "ACCEPTED", a new bank account is created for the - // logged-in user. Preserved verbatim from the Lift implementation. + // Side effect: when status == "ACCEPTED", a new bank account is created and the + // APPLICANT (the application's user) becomes its holder. The Lift implementation + // (and its verbatim port) made the logged-in approver the holder — fixed 2026-09. val updateAccountApplicationStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "banks" / _ / "account-applications" / accountApplicationIdStr => @@ -3074,24 +3075,36 @@ object Http4s310 { _ <- NewStyle.function.tryons(s"$InvalidJsonFormat status should not be blank.", 400, Some(cc)) { org.apache.commons.lang3.Validate.notBlank(putJson.status) } - (_, _) <- NewStyle.function.getAccountApplicationById(accountApplicationIdStr, Some(cc)) - (accountApplication, _) <- NewStyle.function.updateAccountApplicationStatus(accountApplicationIdStr, putJson.status, Some(cc)) - userIdOpt = Option(accountApplication.userId) - customerIdOpt = Option(accountApplication.customerId) + (applicationBefore, _) <- NewStyle.function.getAccountApplicationById(accountApplicationIdStr, Some(cc)) + userIdOpt = Option(applicationBefore.userId) + customerIdOpt = Option(applicationBefore.customerId) appUser <- unboxOptionOBPReturnType(userIdOpt.map(NewStyle.function.findByUserId(_, Some(cc)))) customer <- unboxOptionOBPReturnType(customerIdOpt.map(NewStyle.function.getCustomerByCustomerId(_, Some(cc)))) + // Guard BEFORE the status transition commits: failing after it would strand the + // application as ACCEPTED with no account. A consent-user applicant can only come + // from a row that predates the creation-side guard (or was written another way). + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserId The application's user is a consent user (an agent identity minted by a Consent). Accounts are held by humans - re-apply with the granting user's USER_ID.", + failCode = 400, cc = Some(cc))(!appUser.exists(_.isConsentUser)) + (accountApplication, _) <- NewStyle.function.updateAccountApplicationStatus(accountApplicationIdStr, putJson.status, Some(cc)) _ <- putJson.status match { case "ACCEPTED" => + // The APPLICANT becomes the holder. The Lift-era code (ported verbatim) made the + // approving admin the holder and left appUser unused — every accepted application + // handed the account to whoever clicked approve. Customer-only applications + // (userId empty) keep the legacy approver-as-holder behaviour: there is no user + // to hold, and refusing here would strand the just-committed ACCEPTED status. for { accountId <- Future(AccountId(java.util.UUID.randomUUID().toString)) + holder = appUser.getOrElse(user) (_, _) <- NewStyle.function.createBankAccount( bank.bankId, accountId, accountApplication.productCode.value, "", "EUR", BigDecimal("0"), - user.name, "", + holder.name, "", List.empty, Some(cc)) success <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess( - bank.bankId, accountId, user, Some(cc)) + bank.bankId, accountId, holder, Some(cc)) } yield success case _ => Future("") } @@ -3227,6 +3240,12 @@ object Http4s310 { org.apache.commons.lang3.Validate.isTrue(postedData.user_id.isDefined || postedData.customer_id.isDefined) } appUser <- unboxOptionOBPReturnType(postedData.user_id.map(NewStyle.function.findByUserId(_, Some(cc)))) + // Explicit target: fail loud rather than redirect (see the entitlement endpoints). + // On ACCEPTED the application's user becomes the account holder, so a consent + // user must be rejected here, before the application is stored. + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Accounts are held by humans - use the granting user's USER_ID.", + failCode = 400, cc = Some(cc))(!appUser.exists(_.isConsentUser)) customer <- unboxOptionOBPReturnType(postedData.customer_id.map(NewStyle.function.getCustomerByCustomerId(_, Some(cc)))) (accountApplication, _) <- NewStyle.function.createAccountApplication( productCode = ProductCode(postedData.product_code),