Skip to content

Commit 631fffc

Browse files
feat: auth for workload APIs (#16702)
Co-authored-by: Cam Kennedy <[email protected]>
1 parent 20230eb commit 631fffc

File tree

43 files changed

+683
-155
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+683
-155
lines changed

airbyte-api/workload-api/src/main/openapi/workload-openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ paths:
248248
$ref: "#/components/schemas/WorkloadQueueCleanLimit"
249249
responses:
250250
"204":
251-
description: Cleaning workload queue successfull
251+
description: Cleaning workload queue successful
252252
/api/v1/workload/queue/depth:
253253
post:
254254
tags:

airbyte-commons-server/src/main/java/io/airbyte/commons/server/authorization/KeycloakTokenValidator.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,16 @@
5454
@Singleton
5555
@Primary
5656
@RequiresAuthMode(AuthMode.OIDC)
57+
// We're not confident about what the identity-provider.type will be when keycloak *should* be
58+
// enabled,
59+
// (we think it's usually "oidc" or "keycloak"). We're more confident about when we definitely
60+
// *don't* want it enabled,
61+
// so here we rule out "generic-oidc" and "simple" explicitly. Otherwise, for now, keycloak is
62+
// enabled.
5763
@Requires(property = "airbyte.auth.identity-provider.type",
5864
notEquals = "generic-oidc")
65+
@Requires(property = "airbyte.auth.identity-provider.type",
66+
notEquals = "simple")
5967
@SuppressWarnings({"PMD.PreserveStackTrace", "PMD.UseTryWithResources", "PMD.UnusedFormalParameter", "PMD.UnusedPrivateMethod",
6068
"PMD.ExceptionAsFlowControl"})
6169
public class KeycloakTokenValidator implements TokenValidator<HttpRequest<?>> {

airbyte-commons-server/src/main/kotlin/io/airbyte/commons/server/authorization/AuthenticationFactory.kt

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,18 @@ class AuthenticationFactory(
2929
}
3030

3131
private fun createAuth(
32-
authUserId: String,
33-
attrs: Map<String, Any>,
32+
subject: String,
33+
claims: Map<String, Any>,
3434
): Authentication {
35-
// Some tokens already have roles assigned. If the token contains roles, use those,
36-
// otherwise resolve the roles for the current identity + request.
37-
val tokenRoles = (attrs["roles"] as? List<*>)?.filterIsInstance<String>()
38-
val roles = tokenRoles ?: resolveRoles(authUserId)
35+
logger.debug { "Creating auth for $subject" }
3936

40-
return Authentication.build(authUserId, roles, attrs)
41-
}
37+
val roles =
38+
roleResolver
39+
.newRequest()
40+
.withClaims(subject, claims)
41+
.withRefsFromCurrentHttpRequest()
42+
.roles()
4243

43-
private fun resolveRoles(authUserId: String): Set<String> =
44-
roleResolver
45-
.Request()
46-
.withAuthUserId(authUserId)
47-
.withCurrentHttpRequest()
48-
.roles()
44+
return Authentication.build(subject, roles, claims)
45+
}
4946
}

airbyte-commons-server/src/main/kotlin/io/airbyte/commons/server/authorization/RoleResolver.kt

Lines changed: 143 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package io.airbyte.commons.server.authorization
66

77
import io.airbyte.api.problems.model.generated.ProblemMessageData
88
import io.airbyte.api.problems.throwable.generated.ForbiddenProblem
9+
import io.airbyte.commons.DEFAULT_USER_ID
910
import io.airbyte.commons.auth.AuthRole
1011
import io.airbyte.commons.auth.AuthRoleConstants
1112
import io.airbyte.commons.auth.OrganizationAuthRole
@@ -19,9 +20,14 @@ import io.airbyte.commons.server.support.CurrentUserService
1920
import io.airbyte.config.Permission
2021
import io.airbyte.config.Permission.PermissionType
2122
import io.airbyte.config.helpers.PermissionHelper
23+
import io.airbyte.data.TokenType
24+
import io.airbyte.featureflag.FeatureFlagClient
25+
import io.airbyte.featureflag.IgnoreTokenRoleClaims
26+
import io.airbyte.featureflag.TokenSubject
2227
import io.github.oshai.kotlinlogging.KotlinLogging
2328
import io.micronaut.http.HttpRequest
2429
import io.micronaut.http.context.ServerRequestContext
30+
import io.micronaut.security.utils.SecurityService
2531
import jakarta.inject.Singleton
2632
import java.util.UUID
2733

@@ -60,33 +66,81 @@ private val logger = KotlinLogging.logger {}
6066
open class RoleResolver(
6167
private val authenticationHeaderResolver: AuthenticationHeaderResolver,
6268
private val currentUserService: CurrentUserService,
69+
private val securityService: SecurityService?,
6370
private val permissionHandler: PermissionHandler,
71+
private val featureFlags: FeatureFlagClient,
6472
) {
65-
inner class Request {
66-
var authUserId: String? = null
73+
data class Subject(
74+
val id: String,
75+
val type: TokenType,
76+
)
77+
78+
fun newRequest() = Request()
79+
80+
inner class Request internal constructor() {
81+
var subject: Subject? = null
82+
var claimedRoles: Set<String>? = null
6783
val props: MutableMap<String, String> = mutableMapOf()
84+
val orgs: MutableSet<UUID> = mutableSetOf()
6885

6986
fun withCurrentUser() =
7087
apply {
71-
authUserId = currentUserService.currentUser.authUserId
88+
subject = Subject(currentUserService.currentUser.authUserId, TokenType.USER)
7289
}
7390

74-
fun withAuthUserId(authUserId: String) =
91+
fun withSubject(
92+
id: String,
93+
type: TokenType,
94+
) = apply {
95+
this.subject = Subject(id, type)
96+
}
97+
98+
fun withCurrentAuthentication() =
7599
apply {
76-
this.authUserId = authUserId
100+
// In community auth, where micronaut auth is disabled, the security service isn't available,
101+
// so we need to manually fall back to the default user.
102+
if (securityService == null) {
103+
withSubject(DEFAULT_USER_ID.toString(), TokenType.USER)
104+
}
105+
106+
securityService?.authentication?.map { auth ->
107+
logger.debug { "Using current authentication object ${auth.name} ${auth.roles} ${auth.attributes}" }
108+
withClaims(auth.name, auth.attributes)
109+
}
77110
}
78111

79-
fun withCurrentHttpRequest() =
112+
fun withClaims(
113+
sub: String,
114+
claims: Map<String, Any>,
115+
) = apply {
116+
// Figure out the subject type and ID.
117+
subject = Subject(sub, TokenType.fromClaims(claims))
118+
119+
// Some tokens have roles in the claims.
120+
// If the token does have roles in the claims, then those are the only roles
121+
// resolved by Request.roles().
122+
val roles = (claims["roles"] as? List<*>)?.filterIsInstance<String>()
123+
if (roles != null) {
124+
claimedRoles = roles.toSet()
125+
}
126+
}
127+
128+
fun withRefsFromCurrentHttpRequest() =
80129
apply {
81-
ServerRequestContext.currentRequest<Any>().map { withHttpRequest(it) }
130+
ServerRequestContext.currentRequest<Any>().map { withRefsFromHttpRequest(it) }
82131
}
83132

84-
fun withHttpRequest(req: HttpRequest<*>) =
133+
fun withRefsFromHttpRequest(req: HttpRequest<*>) =
85134
apply {
86135
val headers = req.headers.asMap(String::class.java, String::class.java)
87136
props.putAll(headers)
88137
}
89138

139+
fun withOrg(organizationId: UUID) =
140+
apply {
141+
orgs.add(organizationId)
142+
}
143+
90144
fun withRef(
91145
key: AuthenticationId,
92146
value: String,
@@ -110,34 +164,86 @@ open class RoleResolver(
110164
* roles() resolves the request details into a set of available roles.
111165
*/
112166
fun roles(): Set<String> {
113-
logger.debug { "Resolving roles for authUserId $authUserId" }
167+
// these make null-checking cleaner
168+
val subject = subject
169+
var claimedRoles = claimedRoles
114170

115-
try {
116-
val user = authUserId
117-
if (user.isNullOrBlank()) {
118-
logger.debug { "Provided authUserId is null or blank, returning empty role set" }
119-
return emptySet()
120-
}
171+
// We'd like to transition away from tokens that hard-code roles in the claims,
172+
// and look them up per-request instead. This will allow for more centralized,
173+
// more consistent code for determining roles, and prevents bugs with having
174+
// changing the set of roles needed by a token.
175+
if (featureFlags.boolVariation(IgnoreTokenRoleClaims, TokenSubject(subject?.id ?: "unknown"))) {
176+
claimedRoles = null
177+
}
178+
179+
// This helps when new roles are added to the set of admin roles.
180+
// "ADMIN" implies a set of other roles (see AuthRole.getInstanceAdminRoles()).
181+
// When a new role is added to that set, any existing tokens in the wild that
182+
// contain hard-coded roles will not have this new role. To deal with that,
183+
// we regenerate the set of admin roles here.
184+
//
185+
// TODO Hopefully, in the near future, we'll move away from having hard-coded roles
186+
// in tokens and *always* generate the roles dynamically during the request processing,
187+
// so that this is no longer needed.
188+
if (claimedRoles?.contains(AuthRoleConstants.ADMIN) == true) {
189+
claimedRoles = AuthRole.getInstanceAdminRoles()
190+
}
191+
192+
logger.debug { "Resolving roles for $subject" }
193+
194+
if (subject == null) {
195+
logger.debug { "subject is null, returning empty role set" }
196+
return emptySet()
197+
}
198+
if (subject.id.isBlank()) {
199+
logger.debug { "subject.id is blank, returning empty role set" }
200+
return emptySet()
201+
}
202+
if (!claimedRoles.isNullOrEmpty()) {
203+
return claimedRoles
204+
}
121205

122-
val workspaceIds = authenticationHeaderResolver.resolveWorkspace(props)?.toSet() ?: emptySet()
123-
val organizationIds = authenticationHeaderResolver.resolveOrganization(props)?.toSet() ?: emptySet()
124-
val authUserIds = authenticationHeaderResolver.resolveAuthUserIds(props) ?: emptySet()
125-
val perms = permissionHandler.getPermissionsByAuthUserId(authUserId)
126-
return resolveRoles(perms, user, workspaceIds, organizationIds, authUserIds)
206+
return try {
207+
when (subject.type) {
208+
// Certain token types have a hard-coded list of roles.
209+
TokenType.WORKLOAD_API -> setOf(AuthRoleConstants.DATAPLANE)
210+
TokenType.EMBEDDED_V1 -> setOf(AuthRoleConstants.EMBEDDED_END_USER)
211+
// Everything else resolves roles via the "permissions" table.
212+
TokenType.DATAPLANE_V1, TokenType.SERVICE_ACCOUNT ->
213+
resolvePermissions(
214+
subject.id,
215+
permissionHandler.getPermissionsByServiceAccountId(UUID.fromString(subject.id)),
216+
)
217+
TokenType.USER -> resolvePermissions(subject.id, permissionHandler.getPermissionsByAuthUserId(subject.id))
218+
}
127219
} catch (e: Exception) {
128-
logger.error(e) { "Failed to resolve roles for authUserId $authUserId" }
220+
logger.error(e) { "Failed to resolve roles for $subject" }
129221
return emptySet()
130222
}
131223
}
132224

225+
private fun resolvePermissions(
226+
subjectId: String,
227+
perms: List<Permission>,
228+
): Set<String> {
229+
logger.debug { "Resolving permissions for $subject and $perms" }
230+
231+
val workspaceIds = authenticationHeaderResolver.resolveWorkspace(props)?.toSet() ?: emptySet()
232+
val resolvedOrgIds = authenticationHeaderResolver.resolveOrganization(props)?.toSet() ?: emptySet()
233+
val authUserIds = authenticationHeaderResolver.resolveAuthUserIds(props) ?: emptySet()
234+
val allOrgIds = orgs + resolvedOrgIds
235+
236+
return resolveRoles(perms, subjectId, workspaceIds, allOrgIds, authUserIds)
237+
}
238+
133239
/**
134240
* requireRole checks whether the request has the given role available,
135241
* and if not it throws ForbiddenProblem.
136242
*/
137243
fun requireRole(role: String) {
138244
if (!roles().contains(role)) {
139245
throw ForbiddenProblem(
140-
ProblemMessageData().message("User does not have the required $role permissions to access the resource(s)."),
246+
ProblemMessageData().message("Caller does not have the required $role permissions to access the resource(s)."),
141247
)
142248
}
143249
}
@@ -155,33 +261,46 @@ open class RoleResolver(
155261
*/
156262
fun resolveRoles(
157263
perms: List<Permission>,
158-
currentAuthUserId: String,
264+
subjectId: String,
159265
workspaceIds: Set<UUID>,
160266
organizationIds: Set<UUID>,
161267
authUserIds: Set<String>,
162268
): Set<String> {
269+
logger.debug {
270+
"Resolving roles for $subjectId with perms=$perms workspaceIds=$workspaceIds organizationIds=$organizationIds authUserIds=$authUserIds"
271+
}
272+
163273
val roles = mutableSetOf(AuthRoleConstants.AUTHENTICATED_USER)
164274

165275
// The SELF role denotes that request refers to the current request's identity.
166276
//
167277
// This relies on the assumption that the AuthenticationHeaderResolver will only
168278
// ever resolve authUserIds for one user (who can have multiple authUserIds).
169279
// TODO Technically, that's a weak assumption and we should make that interface clearer.
170-
if (authUserIds.contains(currentAuthUserId)) {
280+
if (authUserIds.contains(subjectId)) {
171281
roles.add(AuthRoleConstants.SELF)
172282
}
173283

174284
if (perms.any { it.permissionType == PermissionType.INSTANCE_ADMIN }) {
175285
roles.addAll(AuthRole.getInstanceAdminRoles())
176286
}
177287

288+
perms.filter { it.permissionType == PermissionType.DATAPLANE }.forEach {
289+
if (it.workspaceId == null && it.organizationId == null) {
290+
roles.add(AuthRoleConstants.DATAPLANE)
291+
}
292+
}
293+
178294
determineWorkspaceRole(perms, workspaceIds)?.let {
179295
roles.addAll(impliedRoles(it))
180296
}
181297
determineOrganizationRole(perms, organizationIds)?.let {
182298
roles.addAll(impliedRoles(it))
183299
}
184300

301+
logger.debug {
302+
"Resolved roles for $subjectId with perms=$perms workspaceIds=$workspaceIds organizationIds=$organizationIds authUserIds=$authUserIds to $roles"
303+
}
185304
return roles
186305
}
187306
}

airbyte-commons-server/src/main/kotlin/io/airbyte/commons/server/handlers/ResourceBootstrapHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ open class ResourceBootstrapHandler(
6565

6666
// Ensure user has the required permissions to create a workspace
6767
roleResolver
68-
.Request()
68+
.newRequest()
6969
.withCurrentUser()
7070
.withRef(AuthenticationId.ORGANIZATION_ID, organization.organizationId)
7171
.requireRole(AuthRoleConstants.ORGANIZATION_ADMIN)

0 commit comments

Comments
 (0)