Skip to content

Commit 44e4a51

Browse files
feat: Introduce provisioned roles in ACL (#706)
1 parent a32699e commit 44e4a51

File tree

15 files changed

+791
-262
lines changed

15 files changed

+791
-262
lines changed

app/logic/Owners.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ object Owners {
2323
acl: ACL
2424
): List[(String, Set[Permission])] = {
2525
acl.userAccess
26-
.flatMap { case (username, permissions) =>
26+
.flatMap { case (username, aclEntry) =>
27+
val permissions = aclEntry.permissions
2728
if (permissions.exists(_.account == account))
2829
Some(username -> permissions.filter(_.account == account))
2930
else None

app/logic/UserAccess.scala

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@ object UserAccess {
1515
/** A user's basic access. Note that default permissions are available for
1616
* anyone mentioned in the Access list.
1717
*/
18-
def userAccess(username: String, acl: ACL): Option[Set[Permission]] = {
18+
def userAccess(username: String, acl: ACL): Option[Set[Permission]] =
1919
acl.userAccess
2020
.get(username)
21-
.map(permissions => permissions ++ acl.defaultPermissions)
22-
}
21+
.map(_.permissions ++ acl.defaultPermissions)
2322

2423
/** Checks if the username is explicitly mentioned in the provided ACL.
2524
*/

configTools/src/main/scala/com/gu/janus/Validation.scala

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.gu.janus
22

33
import cats.Monoid
4+
import com.gu.janus.model.Permission.allPermissions
45
import com.gu.janus.model.{JanusData, ValidationResult}
56

67
object Validation {
@@ -9,13 +10,8 @@ object Validation {
910
// but based on trial and error it seems to be around 1050
1011
val sizeLimit = 1050
1112

12-
val allPermissions = janusData.access.defaultPermissions ++
13-
janusData.access.userAccess.values.flatten.toSet ++
14-
janusData.admin.userAccess.values.flatten.toSet ++
15-
janusData.support.supportAccess
16-
1713
val largePermissions = for {
18-
largePermission <- allPermissions.filter { perm =>
14+
largePermission <- allPermissions(janusData).filter { perm =>
1915
// session policy limit includes the managed ARNs and inline policy document
2016
val totalLength =
2117
perm.policy // the inline policy document's size
@@ -44,12 +40,7 @@ object Validation {
4440
* unambiguously looked up from the URL.
4541
*/
4642
def permissionUniqueness(janusData: JanusData): ValidationResult = {
47-
val allPermissions = janusData.access.defaultPermissions ++
48-
janusData.access.userAccess.values.flatten.toSet ++
49-
janusData.admin.userAccess.values.flatten.toSet ++
50-
janusData.support.supportAccess
51-
52-
val duplicates = allPermissions
43+
val duplicates = allPermissions(janusData)
5344
.groupBy(_.id)
5445
.filter { case (_, permissions) =>
5546
permissions.size > 1

configTools/src/main/scala/com/gu/janus/config/Loader.scala

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
package com.gu.janus.config
22

3-
import cats.implicits._
4-
import com.gu.janus.model._
3+
import cats.implicits.*
4+
import com.gu.janus.model.*
5+
import com.gu.janus.model.given
56
import com.typesafe.config.Config
6-
import io.circe.Decoder
7-
import io.circe.config.syntax._
8-
import io.circe.generic.auto._
7+
import io.circe.config.syntax.*
8+
import io.circe.generic.auto.*
99

10-
import java.time.{Duration, Instant, ZoneId, ZonedDateTime, format}
1110
import scala.util.Try
12-
import scala.util.control.NonFatal
1311

1412
/** Loads an instance of JanusData from a Typesafe Config definition. If it
1513
* fails, a description of the failure is made available.
@@ -112,21 +110,7 @@ object Loader {
112110
s"The 'default permissions' section of the access definition includes a permission that doesn't appear to be defined.\nIt has label `${configuredAclEntry.label}` and refers to the account with key ${configuredAclEntry.account}"
113111
)
114112
}
115-
acl <- configuredAccess.acl.toList.traverse {
116-
case (username, configuredAclEntries) =>
117-
for {
118-
userPermissions <- configuredAclEntries.traverse {
119-
configuredAclEntry =>
120-
permissions
121-
.find(p =>
122-
configuredAclEntry.account == p.account.authConfigKey && configuredAclEntry.label == p.label
123-
)
124-
.toRight(
125-
s"The access configuration for `$username` includes a permission that doesn't appear to be defined.\nIt has label `${configuredAclEntry.label}` and refers to the account with key ${configuredAclEntry.account}"
126-
)
127-
}
128-
} yield username -> userPermissions.toSet
129-
}
113+
acl <- parseAclEntries(configuredAccess.acl, permissions)
130114
} yield ACL(acl.toMap, defaultAccess.toSet)
131115
}
132116

@@ -141,27 +125,49 @@ object Loader {
141125
.map(err =>
142126
s"Failed to load admin config from path `janus.admin`: ${err.getMessage}"
143127
)
144-
acl <- configuredAccess.acl.toList.traverse {
145-
case (username, configuredAclEntries) =>
146-
for {
147-
userPermissions <- configuredAclEntries.traverse {
148-
configuredAclEntry =>
149-
permissions
150-
.find(p =>
151-
configuredAclEntry.account == p.account.authConfigKey && configuredAclEntry.label == p.label
152-
)
153-
.toRight(
154-
s"The admin configuration for `$username` includes a permission that doesn't appear to be defined.\nIt has label `${configuredAclEntry.label}` and refers to the account with key ${configuredAclEntry.account}"
155-
)
156-
}
157-
} yield username -> userPermissions.toSet
158-
}
128+
acl <- parseAclEntries(configuredAccess.acl, permissions)
159129
} yield ACL(
160130
acl.toMap,
161131
Set.empty
162132
) // TODO: these shouldn't share a representation since Admin doesn't need the default permissions
163133
}
164134

135+
private[config] def parseAclEntries(
136+
acl: Map[String, List[ConfiguredAclEntry | ConfiguredRoleAclEntry]],
137+
permissions: Set[Permission]
138+
): Either[String, List[(String, ACLEntry)]] = {
139+
val permsMap =
140+
permissions.map(p => (p.account.authConfigKey, p.label) -> p).toMap
141+
acl.toList.traverse { case (username, configuredAclEntries) =>
142+
configuredAclEntries
143+
.foldLeft[Either[String, ACLEntry]](
144+
Right(ACLEntry(Set.empty, Set.empty))
145+
) {
146+
case (acc, entry: ConfiguredAclEntry) =>
147+
for {
148+
current <- acc
149+
permission <- permsMap
150+
.get((entry.account, entry.label))
151+
.toRight(
152+
s"The access configuration for `$username` includes a permission that doesn't appear to be defined.\nIt has label `${entry.label}` and refers to the account with key ${entry.account}"
153+
)
154+
} yield ACLEntry(current.permissions + permission, current.roles)
155+
156+
case (acc, entry: ConfiguredRoleAclEntry) =>
157+
acc.map(current =>
158+
ACLEntry(
159+
current.permissions,
160+
current.roles + ProvisionedRole(
161+
entry.provisionedRoleName,
162+
entry.iamRoleTag
163+
)
164+
)
165+
)
166+
}
167+
.map(username -> _)
168+
}
169+
}
170+
165171
private[config] def loadSupport(
166172
config: Config,
167173
permissions: Set[Permission]

configTools/src/main/scala/com/gu/janus/config/Writer.scala

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.gu.janus.config
22

3-
import com.gu.janus.model.{JanusData, Permission}
3+
import com.gu.janus.model.JanusData
4+
import com.gu.janus.model.Permission.allPermissions
45

56
object Writer {
67
def toConfig(janusData: JanusData): String = {
@@ -10,13 +11,6 @@ object Writer {
1011
)
1112
}
1213

13-
private[config] def allPermissions(janusData: JanusData): Set[Permission] = {
14-
janusData.access.defaultPermissions ++
15-
janusData.access.userAccess.values.flatten.toSet ++
16-
janusData.admin.userAccess.values.flatten.toSet ++
17-
janusData.support.supportAccess
18-
}
19-
2014
/** Twirl is designed for HTML, not plain text. As a result it's tricky to
2115
* control how whitespace (particularly newlines) get added to the file.
2216
*

configTools/src/main/scala/com/gu/janus/model/configuredRepresentation.scala

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package com.gu.janus.model
22

3+
import cats.implicits.toFunctorOps
4+
import io.circe.Decoder
5+
import io.circe.generic.auto.deriveDecoder
6+
37
import java.time.Instant
48

59
case class ConfiguredAccount(
@@ -31,14 +35,27 @@ case class ConfiguredAclEntry(
3135
label: String
3236
)
3337

38+
case class ConfiguredRoleAclEntry(
39+
provisionedRoleName: String,
40+
iamRoleTag: String
41+
)
42+
43+
given Decoder[ConfiguredAclEntry | ConfiguredRoleAclEntry] =
44+
Decoder[ConfiguredAclEntry]
45+
.widen[ConfiguredAclEntry | ConfiguredRoleAclEntry]
46+
.or(
47+
Decoder[ConfiguredRoleAclEntry]
48+
.widen[ConfiguredAclEntry | ConfiguredRoleAclEntry]
49+
)
50+
3451
case class ConfiguredAccess(
3552
defaultPermissions: List[ConfiguredAclEntry],
36-
acl: Map[String, List[ConfiguredAclEntry]]
53+
acl: Map[String, List[ConfiguredAclEntry | ConfiguredRoleAclEntry]]
3754
)
3855

3956
// helps circe-config auto-extract data
4057
case class ConfiguredAdmin(
41-
acl: Map[String, List[ConfiguredAclEntry]]
58+
acl: Map[String, List[ConfiguredAclEntry | ConfiguredRoleAclEntry]]
4259
)
4360

4461
case class ConfiguredSupport(

configTools/src/main/scala/com/gu/janus/model/models.scala

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ case class JanusData(
1414
)
1515

1616
case class ACL(
17-
userAccess: Map[String, Set[Permission]],
17+
userAccess: Map[String, ACLEntry],
1818
defaultPermissions: Set[Permission] = Set.empty
1919
)
20+
21+
/** Access available to a single user. */
22+
case class ACLEntry(
23+
permissions: Set[Permission],
24+
roles: Set[ProvisionedRole]
25+
)
26+
2027
case class SupportACL private (
2128
rota: Map[Instant, (String, String)],
2229
supportAccess: Set[Permission]
@@ -149,8 +156,32 @@ object Permission {
149156
shortTerm
150157
)
151158
}
159+
160+
def allPermissions(janusData: JanusData): Set[Permission] = {
161+
def perms(
162+
access: Map[String, ACLEntry]
163+
): Set[Permission] =
164+
access.values.flatMap(_.permissions).toSet
165+
166+
janusData.access.defaultPermissions ++
167+
perms(janusData.access.userAccess) ++
168+
perms(janusData.admin.userAccess) ++
169+
janusData.support.supportAccess
170+
}
152171
}
153172

173+
/** A set of provisioned IAM roles that Janus can discover by tag lookup. */
174+
case class ProvisionedRole(
175+
/** A friendly name to identify this in a UI or elsewhere. */
176+
name: String,
177+
178+
/** Hook that will allow us to discover the IAM roles included in this set.
179+
* Each relevant role will be found by a tag identifying it as a Janus role
180+
* and a tag that matches this value.
181+
*/
182+
iamRoleTag: String
183+
)
184+
154185
sealed abstract class JanusAccessType(override val toString: String)
155186
object JCredentials extends JanusAccessType("credentials")
156187
object JConsole extends JanusAccessType("console")

configTools/src/main/twirl/com/gu/janus/config/templates/janusData.scala.txt

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@import com.gu.janus.model.{JanusData, Permission}
1+
@import com.gu.janus.model.{JanusData, Permission, ProvisionedRole}
22

33
@(janusData: JanusData, allPermissions: Set[Permission])
44

@@ -38,22 +38,28 @@ janus {
3838
]
3939

4040
acl {
41-
@for((user, permissions) <- janusData.access.userAccess){
41+
@for((user, aclEntry) <- janusData.access.userAccess){
4242
"@user" = [
43-
@for(permission <- permissions){
44-
@permissionReference(permission),
43+
@for(permission <- aclEntry.permissions) {
44+
@permissionReference(permission)
45+
}
46+
@for(role <- aclEntry.roles) {
47+
{ provisionedRoleName = """@role.name""", iamRoleTag = "@role.iamRoleTag" }
4548
}
4649
]
47-
}
50+
},
4851
}
4952
}
5053

5154
admin {
5255
acl {
53-
@for((user, permissions) <- janusData.admin.userAccess){
56+
@for((user, aclEntry) <- janusData.admin.userAccess){
5457
"@user" = [
55-
@for(permission <- permissions){
56-
@permissionReference(permission),
58+
@for(permission <- aclEntry.permissions) {
59+
@permissionReference(permission)
60+
}
61+
@for(role <- aclEntry.roles) {
62+
{ provisionedRoleName = """@role.name""", iamRoleTag = "@role.iamRoleTag" }
5763
}
5864
]
5965
}
@@ -65,7 +71,7 @@ janus {
6571
{
6672
account = "@permission.account.authConfigKey"
6773
label = "@permission.label"
68-
description = "@permission.description"
74+
description = """@permission.description"""
6975
shortTerm = @if(permission.shortTerm) {true} else {false}
7076
@permission.policy match {
7177
case Some(policy) => {

configTools/src/test/resources/example.conf

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ janus {
3939

4040
acl {
4141
employee1 = [
42-
{ account = "website", label = "developer" }
42+
{ account = "website", label = "developer" },
43+
{ provisionedRoleName = """Crucial App Developer""", iamRoleTag = "crucial-app-developer" }
44+
{ provisionedRoleName = """The "Super" App Local Developer""", iamRoleTag = "super-app-developer" }
4345
]
4446
employee2 = [
4547
{ account = "website", label = "developer" }
@@ -59,6 +61,7 @@ janus {
5961
acl {
6062
employee1 = [
6163
{ account = "website", label = "admin" }
64+
{ provisionedRoleName = """The "Super" App Superuser""", iamRoleTag = "super-app-admin" }
6265
]
6366
}
6467
}

0 commit comments

Comments
 (0)