Skip to content

Commit 4477e51

Browse files
authored
Merge pull request #2903 from simonredfern/develop
auth_type metrics column etc.
2 parents 9a977ad + da5a64b commit 4477e51

30 files changed

Lines changed: 491 additions & 112 deletions

‎obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3202,6 +3202,7 @@ object SwaggerDefinitionsJSON {
32023202
operation_id = "OBPv4.0.0-getBanks",
32033203
api_instance_id = "obp_node_a",
32043204
consent_reference_id = Some(ExampleValue.consentReferenceIdExample.value),
3205+
auth_type = Some("Consent"),
32053206
certificate_trust = Some("forwarded"),
32063207
certificate_trust_detail = Some("cn=nginx-prod-1,ou=edge,o=tesobe gmbh,c=de")
32073208
)

‎obp-api/src/main/scala/code/api/constant/constant.scala‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ object Constant extends MdcLoggable {
1616

1717
final val directLoginHeaderName = "DirectLogin"
1818

19+
// createdByProcess of entitlement rows the consent engine copies onto a consent user —
20+
// the per-consent principal a Consent-JWT authenticates as (its ResourceUser row carries
21+
// CreatedByConsentId). Only rows tagged with this value may target a consent user:
22+
// addEntitlement redirects any other grant to the consent's granting human, so durable
23+
// roles (e.g. bank-creator grants) can never strand on a principal that dies with its
24+
// consent. Also the marker for cleaning these rows up when the consent is revoked.
25+
final val consent_user = "consent_user"
26+
27+
// createdByProcess of entitlement rows granted through group membership (the Groups
28+
// feature). The value predates this constant: the Groups feature originally wrote it to
29+
// its own `process` column, a duplicate of createdByProcess since retired — provenance
30+
// now lives in createdByProcess like every other granting mechanism, and group rows are
31+
// identified by their group_id.
32+
final val group_membership = "GROUP_MEMBERSHIP"
33+
1934
object Pagination {
2035
final val offset = 0
2136
final val limit = 50

‎obp-api/src/main/scala/code/api/util/ApiSession.scala‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ case class CallContext(
3939
// the creator is the granting human (they create their own consent in the Portal).
4040
// Not set by Berlin Group / UK flows, where the consent may be created by a TPP flow
4141
// with no human logged in — see `consenter` for those.
42-
// Read via humanUser / effectiveHumanUserId, where it takes precedence over consenter.
42+
// Read via humanUser / accountableUserId, where it takes precedence over consenter.
4343
onBehalfOfUser: Box[User] = Empty,
4444
// The human (PSU) who AUTHORISED the consent this request runs under — the owner of
4545
// record, from the consent table's userId (bound by updateConsentUser during the
@@ -113,7 +113,7 @@ case class CallContext(
113113
* Anything that must name a human rather than a principal reads this instead: the CBS adapter,
114114
* which tells the core banking system who is asking, and the consent ownership checks.
115115
* Stored data (metric rows included) always carries the authenticated principal; the human is
116-
* resolved at read time via the consent table (see effectiveHumanUserId).
116+
* resolved at read time via the consent table (see accountableUserId).
117117
*/
118118
def humanUser: Box[User] = onBehalfOfUser.or(consenter).or(user)
119119

@@ -182,7 +182,7 @@ case class CallContext(
182182
// (CallContext.user), never a resolved human. Under a consent that principal is the
183183
// consent's own shadow user (a per-consent UUID with an empty name) — the on-behalf-of
184184
// human is not stored here but resolved at read time via the consent table
185-
// (consentReferenceId below -> consent.userId), see CallContext.effectiveHumanUserId.
185+
// (consentReferenceId below -> consent.userId), see CallContext.accountableUserId.
186186
userId = this.user.map(_.userId).toOption,
187187
userName = this.user.map(_.name).toOption,
188188
consumerId = this.consumer.map(_.consumerId.get).toOption,
@@ -217,22 +217,31 @@ case class CallContext(
217217
def userId: String = user.map(_.userId).openOrThrowException(AuthenticatedUserIsRequired)
218218

219219
/**
220-
* The human User this request is really about.
220+
* The ACCOUNTABLE identity this request is really about — the user_id that durable
221+
* state (creator role grants, account holders, entitlement requests) and attribution
222+
* (metrics families, "my" queries) bind to. "Accountable" deliberately hints at a
223+
* legal person: today resolution always ends at the human who granted the consent,
224+
* but the contract is accountability, not species — if durable, sponsored agent
225+
* identities are ever admitted as principals in their own right, resolution may stop
226+
* at such an agent without this name becoming a lie (unlike the previous name,
227+
* effectiveHumanUserId).
221228
*
222-
* The authenticated `user` may be the human themselves, or an agent user minted by a
223-
* Consent the human granted (e.g. Opey / MCP acting under a consent). Resolution order:
229+
* The authenticated `user` may be the accountable party themselves, or a consent user
230+
* minted by a Consent they granted (e.g. Opey / MCP acting under a consent) — consent
231+
* users are ephemeral and must never hold durable state (see addEntitlement's guard).
232+
* Resolution order:
224233
* 1. `onBehalfOfUser` or `consenter`, when a middleware populated them (free);
225234
* 2. otherwise resolve via the delegation registry: the caller's ResourceUser row's
226235
* CreatedByConsentId names the Consent that minted it, and that Consent's userId
227236
* names the granting human;
228-
* 3. otherwise the caller IS the human.
237+
* 3. otherwise the caller IS the accountable party.
229238
*
230239
* IMPORTANT: this reads only the authenticated user and server-written columns
231240
* (ResourceUser.CreatedByConsentId, MappedConsent.mUserId). It deliberately takes no
232241
* parameters so nothing caller-asserted (body/header/query values) can ever influence
233242
* the resolution — identity-sensitive queries (e.g. /my/banks) depend on that.
234243
*/
235-
def effectiveHumanUserId: String = {
244+
def accountableUserId: String = {
236245
val delegatedHumanUserId = onBehalfOfUser.or(consenter).map(_.userId).filter(_.nonEmpty)
237246
delegatedHumanUserId.openOr {
238247
val authenticatedUserId = user.map(_.userId).openOr("")

‎obp-api/src/main/scala/code/api/util/ConsentUtil.scala‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,10 @@ object Consent extends MdcLoggable {
380380
existingEntitlements.exists(_.roleName == entitlement.role_name) match { // Check is a role already added to a user
381381
case false =>
382382
val bankId = if (role.requiresBankId) entitlement.bank_id else ""
383-
Entitlement.entitlement.vend.addEntitlement(bankId, user.userId, entitlement.role_name) match {
383+
// Tagged consent_user: this is the ONE writer allowed to target a consent
384+
// user — addEntitlement redirects untagged grants to the granting human.
385+
Entitlement.entitlement.vend.addEntitlement(bankId, user.userId, entitlement.role_name,
386+
createdByProcess = Constant.consent_user) match {
384387
case Full(_) => (entitlement, "AddedOrExisted")
385388
case _ =>
386389
(entitlement, CannotAddEntitlement + entitlement)
@@ -905,7 +908,7 @@ object Consent extends MdcLoggable {
905908
} yield {
906909
(principal, callContext.copy(
907910
// The PSU stays reachable for everything that needs a human: the CBS adapter, metric
908-
// attribution, and CallContext.effectiveHumanUserId.
911+
// attribution, and CallContext.accountableUserId.
909912
consenter = Full(psu),
910913
ukConsentId = Some(storedConsent.consentId),
911914
consentReferenceId = Some(storedConsent.consentReferenceId)

‎obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ object WriteMetricUtil extends MdcLoggable {
4545
duration: Long,
4646
responseBodyToWrite: String,
4747
sourceIp: String,
48-
targetIp: String)
48+
targetIp: String,
49+
authType: String)
4950

5051
private def persistAndPublishMetric(responseBody: Any, cc: CallContextLight): Unit = {
5152
val fields = MetricFields(
@@ -58,7 +59,8 @@ object WriteMetricUtil extends MdcLoggable {
5859
duration = callDuration(cc),
5960
responseBodyToWrite = responseBodyForMetric(responseBody, cc),
6061
sourceIp = requestHeaderValue(cc, "x-forwarded-for"),
61-
targetIp = requestHeaderValue(cc, "x-forwarded-host")
62+
targetIp = requestHeaderValue(cc, "x-forwarded-host"),
63+
authType = deriveAuthType(cc)
6264
)
6365

6466
// enqueue synchronously so flush() in tests reliably drains this metric before assertions
@@ -74,6 +76,30 @@ object WriteMetricUtil extends MdcLoggable {
7476
}
7577
}
7678

79+
/**
80+
* Authentication SCHEME of the call — never the credential itself. "Consent" wins
81+
* outright: when a consent authenticated the call, the Authorization header (if any)
82+
* was not what authorized it. The rest is read off the Authorization header shape,
83+
* with the gateway payload / direct-login params as fallbacks for flows that
84+
* populate those without a header.
85+
*/
86+
private[util] def deriveAuthType(cc: CallContextLight): String = {
87+
if (cc.consentReferenceId.isDefined) "Consent"
88+
else cc.authReqHeaderField.map(_.trim) match {
89+
case Some(h) if h.startsWith("DirectLogin") => "DirectLogin"
90+
case Some(h) if h.startsWith("Bearer") => "OAuth2"
91+
case Some(h) if h.startsWith("GatewayLogin") => "GatewayLogin"
92+
case Some(h) if h.startsWith("DAuth") => "DAuth"
93+
case Some(h) if h.startsWith("OAuth") => "OAuth1"
94+
case Some(_) => "Other"
95+
case None =>
96+
if (cc.gatewayLoginRequestPayload.isDefined) "GatewayLogin"
97+
else if (cc.directLoginToken != null && cc.directLoginToken.nonEmpty) "DirectLogin"
98+
else if (cc.userId.isDefined) "Other"
99+
else "Anonymous"
100+
}
101+
}
102+
77103
private def callDuration(cc: CallContextLight): Long =
78104
(cc.startTime, cc.endTime) match {
79105
case (Some(s), Some(e)) => e.getTime - s.getTime
@@ -116,7 +142,8 @@ object WriteMetricUtil extends MdcLoggable {
116142
code.api.Constant.ApiInstanceId,
117143
cc.consentReferenceId.orNull,
118144
cc.certificateTrust.orNull,
119-
cc.certificateTrustDetail.orNull
145+
cc.certificateTrustDetail.orNull,
146+
authType
120147
)
121148
} catch {
122149
case NonFatal(e) =>

‎obp-api/src/main/scala/code/api/util/migration/Migration.scala‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ object Migration extends MdcLoggable {
157157
migrateChatRoomCreatedByAndLastMessageSender()
158158
migrateConsentReferenceIdToUuid(startedBeforeSchemifier)
159159
migrateMetricConsentReferenceId(startedBeforeSchemifier)
160+
migrateMetricAuthType(startedBeforeSchemifier)
160161
migrateMetricCertificateTrust(startedBeforeSchemifier)
161162
dropFastFirehoseAccountsViews(startedBeforeSchemifier)
162163
alterDynamicResourceDocBodyFieldsLength()
@@ -808,6 +809,18 @@ object Migration extends MdcLoggable {
808809
}
809810
}
810811

812+
private def migrateMetricAuthType(startedBeforeSchemifier: Boolean): Boolean = {
813+
if(startedBeforeSchemifier == true) {
814+
logger.warn(s"Migration.database.migrateMetricAuthType(true) cannot be run before Schemifier.")
815+
true
816+
} else {
817+
val name = nameOf(migrateMetricAuthType(startedBeforeSchemifier))
818+
runOnce(name) {
819+
MigrationOfMetricAuthType.migrate(name)
820+
}
821+
}
822+
}
823+
811824
private def migrateMetricCertificateTrust(startedBeforeSchemifier: Boolean): Boolean = {
812825
if(startedBeforeSchemifier == true) {
813826
logger.warn(s"Migration.database.migrateMetricCertificateTrust(true) cannot be run before Schemifier.")

‎obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ object MigrationOfActivityDashboardIndexes {
8888
* Index on resourceuser.createdbyconsentid.
8989
*
9090
* The delegation registry: consent-agent fan-down (/my/metrics, /my/banks) and
91-
* CallContext.effectiveHumanUserId look up agent users by the consent that minted them.
91+
* CallContext.accountableUserId look up agent users by the consent that minted them.
9292
* Unindexed this is a full scan of resourceuser on every such request, which matters on
9393
* consent-heavy instances where every consent mints a user row.
9494
*/
@@ -130,7 +130,7 @@ object MigrationOfActivityDashboardIndexes {
130130
s"""Added index on resourceuser.createdbyconsentid
131131
|Executed SQL:
132132
|$executedSql
133-
|Serves the consent-agent delegation fan-down (/my/metrics, /my/banks, effectiveHumanUserId).
133+
|Serves the consent-agent delegation fan-down (/my/metrics, /my/banks, accountableUserId).
134134
|""".stripMargin
135135
isSuccessful = true
136136
saveLog(name, commitId, isSuccessful, startDate, endDate, comment)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package code.api.util.migration
2+
3+
import code.api.util.APIUtil
4+
import code.api.util.migration.Migration.{DbFunction, saveLog}
5+
import code.metrics.MappedMetric
6+
import net.liftweb.mapper.Schemifier
7+
8+
/**
9+
* Migration: add `auth_type VARCHAR(32)` to both the live `Metric` table and the
10+
* `metricarchive` table — the authentication SCHEME of each call ("Consent",
11+
* "OAuth2", "OAuth1", "DirectLogin", "GatewayLogin", "DAuth", "Anonymous",
12+
* "Other"), never the credential itself.
13+
*
14+
* No backup and no backfill: the column is additive and nullable — historical rows
15+
* legitimately predate it and stay null. No index: always queried alongside the
16+
* indexed date range.
17+
*
18+
* Lift's Schemifier auto-creates the column on fresh deploys from the updated model;
19+
* this migration handles existing deploys. Table name note as in
20+
* MigrationOfMetricConsentReferenceId: unquoted lowercase `metric` everywhere.
21+
*/
22+
object MigrationOfMetricAuthType {
23+
24+
def migrate(name: String): Boolean = {
25+
DbFunction.tableExists(MappedMetric) match {
26+
case true =>
27+
val startDate = System.currentTimeMillis()
28+
val commitId: String = APIUtil.gitCommit
29+
val dbDriver = APIUtil.getPropsValue("db.driver") openOr "org.h2.Driver"
30+
val isMssql = dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver")
31+
var isSuccessful = false
32+
val sqlLog = new StringBuilder()
33+
34+
try {
35+
val addColumnMetric = if (isMssql) {
36+
"ALTER TABLE metric ADD auth_type VARCHAR(32) NULL;"
37+
} else {
38+
"ALTER TABLE metric ADD COLUMN IF NOT EXISTS auth_type VARCHAR(32);"
39+
}
40+
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => addColumnMetric)).append("\n")
41+
42+
val addColumnArchive = if (isMssql) {
43+
"ALTER TABLE metricarchive ADD auth_type VARCHAR(32) NULL;"
44+
} else {
45+
"ALTER TABLE metricarchive ADD COLUMN IF NOT EXISTS auth_type VARCHAR(32);"
46+
}
47+
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => addColumnArchive)).append("\n")
48+
49+
isSuccessful = true
50+
} catch {
51+
case e: Exception =>
52+
isSuccessful = false
53+
sqlLog.append(s"\nException: ${e.getMessage}\n")
54+
}
55+
56+
val endDate = System.currentTimeMillis()
57+
val comment: String =
58+
s"""Executed SQL:
59+
|$sqlLog
60+
|""".stripMargin
61+
saveLog(name, commitId, isSuccessful, startDate, endDate, comment)
62+
isSuccessful
63+
64+
case false =>
65+
val startDate = System.currentTimeMillis()
66+
val commitId: String = APIUtil.gitCommit
67+
val isSuccessful = false
68+
val endDate = System.currentTimeMillis()
69+
val comment: String = s"""${MappedMetric._dbTableNameLC} table does not exist""".stripMargin
70+
saveLog(name, commitId, isSuccessful, startDate, endDate, comment)
71+
isSuccessful
72+
}
73+
}
74+
}

‎obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -845,8 +845,14 @@ object Http4s200 {
845845
isValidID(bank.bankId.value)
846846
}
847847
loggedInUserId = user.userId
848-
userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId
848+
// Implicit owner resolves to the HUMAN: under a Consent the caller is the
849+
// per-consent shadow, and an account held by it strands when the consent dies.
850+
userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else cc.accountableUserId
849851
(postedOrLoggedInUser, cc2) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc))
852+
// Explicit target: fail loud rather than redirect (see the entitlement endpoints).
853+
_ <- code.util.Helper.booleanToFuture(
854+
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.",
855+
failCode = 400, cc = cc2)(!postedOrLoggedInUser.isConsentUser)
850856
_ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(()))
851857
else code.util.Helper.booleanToFuture(
852858
s"${UserHasMissingRoles} $canCreateAccount or create account for self", failCode = 403, cc = Some(cc)) {
@@ -1188,7 +1194,13 @@ object Http4s200 {
11881194
case req @ POST -> `prefixPath` / "users" / userId / "entitlements" =>
11891195
EndpointHelpers.withUserAndBodyCreated[CreateEntitlementJSON, EntitlementJSON](req) { (user, body, cc) =>
11901196
for {
1191-
(_, cc2) <- NewStyle.function.findByUserId(userId, Some(cc))
1197+
(targetUser, cc2) <- NewStyle.function.findByUserId(userId, Some(cc))
1198+
// Explicit target: fail loud rather than redirect. A consent user (an agent
1199+
// identity minted by a Consent) cannot hold durable roles — grant to the
1200+
// granting human instead.
1201+
_ <- code.util.Helper.booleanToFuture(
1202+
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.",
1203+
failCode = 400, cc = cc2)(!targetUser.isConsentUser)
11921204
role <- Future {
11931205
unboxFullOrFail(
11941206
net.liftweb.util.Helpers.tryo { ApiRole.valueOf(body.role_name) },

‎obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -465,17 +465,20 @@ object Http4s220 {
465465
bank.swift_bic, bank.national_identifier,
466466
bank.bank_routing.scheme, bank.bank_routing.address, Some(cc)
467467
)
468+
// Creator grants target the HUMAN (see v6.0.0 createBank): under a Consent the
469+
// authenticated user is a per-consent shadow, and roles granted to it are stranded.
470+
humanUserId = cc.accountableUserId
468471
entitlements <- Future {
469472
unboxFullOrFail(
470-
code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(user.userId),
473+
code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(humanUserId),
471474
Some(cc), UnknownError)
472475
}
473476
_ <- Future {
474477
val bankEntitlements = entitlements.filter(_.bankId == bank.id)
475478
if (!bankEntitlements.exists(_.roleName == canCreateEntitlementAtOneBank.toString()))
476-
code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, user.userId, canCreateEntitlementAtOneBank.toString())
479+
code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, humanUserId, canCreateEntitlementAtOneBank.toString(), grantedByUserId = Some(user.userId))
477480
if (!bankEntitlements.exists(_.roleName == canReadDynamicResourceDocsAtOneBank.toString()))
478-
code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, user.userId, canReadDynamicResourceDocsAtOneBank.toString())
481+
code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, humanUserId, canReadDynamicResourceDocsAtOneBank.toString(), grantedByUserId = Some(user.userId))
479482
}
480483
} yield JSONFactory220.createBankJSON(success)
481484
}

0 commit comments

Comments
 (0)