app/logic/AuditTrail.scala (147 lines of code) (raw):
package logic
import com.gu.googleauth.UserIdentity
import com.gu.janus.model.{ACL, AuditLog, JanusAccessType, Permission}
import logic.UserAccess.{hasExplicitAccess, username}
import play.api.Logging
import software.amazon.awssdk.services.dynamodb.model.AttributeValue
import java.time.{Duration, Instant}
import scala.util.Try
object AuditTrail extends Logging {
val tableName = "AuditTrail"
val secondaryIndexName = "AuditTrailByUser"
/* Database item attributes.
* Named with a 'j_' prefix to avoid conflicts with DynamoDB reserved keywords.
*/
// AWS account name - used as the partition key for the table
val accountPartitionKeyName = "j_account"
// Timestamp of the access attempt - used as the sort key for the table and for its secondary index
val timestampSortKeyName = "j_timestamp"
// User name - indexed attribute
val userNameAttrName = "j_username"
// TTL of the granted session, in seconds
val durationAttrName = "j_duration"
// Access role requested
val accessLevelAttrName = "j_accessLevel"
// Whether access request was for AWS console or credentials for local use
val accessTypeAttrName = "j_accessType"
val isExternalAttrName = "j_external"
/** Database item attributes for a single audit log entry. */
case class AuditLogDbEntryAttrs(
partitionKey: (String, AttributeValue),
sortKey: (String, AttributeValue),
userName: (String, AttributeValue),
sessionDuration: (String, AttributeValue),
accessLevel: (String, AttributeValue),
accessType: (String, AttributeValue),
isExternal: (String, AttributeValue)
) {
val toMap: Map[String, AttributeValue] = Seq(
partitionKey,
sortKey,
userName,
sessionDuration,
accessLevel,
accessType,
isExternal
).toMap
}
object AuditLogDbEntryAttrs {
/** Converts an AuditLog into its DB representation. */
def fromAuditLog(auditLog: AuditLog): AuditLogDbEntryAttrs =
AuditLogDbEntryAttrs(
partitionKey =
accountPartitionKeyName -> AttributeValue.fromS(auditLog.account),
sortKey = {
val accessTime = auditLog.instant.toEpochMilli
timestampSortKeyName -> AttributeValue.fromN(accessTime.toString)
},
userName = userNameAttrName -> AttributeValue.fromS(auditLog.username),
sessionDuration = durationAttrName -> AttributeValue.fromN(
auditLog.duration.getSeconds.toString
),
accessLevel =
accessLevelAttrName -> AttributeValue.fromS(auditLog.accessLevel),
accessType = accessTypeAttrName -> AttributeValue.fromS(
auditLog.accessType.toString
),
isExternal = isExternalAttrName -> AttributeValue.fromN(
(if (auditLog.external) 1 else 0).toString
)
)
}
/** Create an audit log entry for a user's access attempt. */
def createLog(
user: UserIdentity,
permission: Permission,
janusAccessType: JanusAccessType,
duration: Duration,
acl: ACL
): AuditLog =
AuditLog(
permission.account.authConfigKey,
username(user),
Instant.now(),
duration,
permission.label,
janusAccessType,
!hasExplicitAccess(username(user), permission, acl)
)
/** Extract nice error message from db conversion.
*/
def errorStrings(
error: Either[(String, Map[String, AttributeValue]), AuditLog]
): Either[String, AuditLog] = {
error.left.map(_._1)
}
/** Log detailed info for any DB result extraction errors.
*/
def logDbResultErrs(
attempt: Either[(String, Map[String, AttributeValue]), AuditLog]
): Either[(String, Map[String, AttributeValue]), AuditLog] = {
attempt.left.foreach { case (_, attrs) =>
val formattedAttrs = attrs.map { case (name, value) =>
s"$name: ${value.toString}"
} mkString ", "
logger.error(s"Failed to extract auditLog data $formattedAttrs")
}
attempt
}
/** (Attempt to) convert a database result into an audit log.
*/
def auditLogFromAttrs(
attrs: Map[String, AttributeValue]
): Either[(String, Map[String, AttributeValue]), AuditLog] = {
for {
account <- stringValue(attrs, accountPartitionKeyName).toRight(
"Could not extract account" -> attrs
)
username <- stringValue(attrs, userNameAttrName).toRight(
"Could not extract username" -> attrs
)
dateTime <- longValue(attrs, timestampSortKeyName)
.map(epochMilli => Instant.ofEpochMilli(epochMilli))
.toRight("Could not extract dateTime" -> attrs)
duration <- longValue(attrs, durationAttrName)
.map(Duration.ofSeconds)
.toRight("Could not extract duration" -> attrs)
accessLevel <- stringValue(attrs, accessLevelAttrName).toRight(
"Could not extract accessLevel" -> attrs
)
accessType <- stringValue(attrs, accessTypeAttrName)
.flatMap(JanusAccessType.fromString)
.toRight("Could not extract accessType" -> attrs)
external <- longValue(attrs, isExternalAttrName)
.flatMap {
case 0 => Some(false)
case 1 => Some(true)
case _ => None
}
.toRight("Could not extract external" -> attrs)
} yield AuditLog(
account,
username,
dateTime,
duration,
accessLevel,
accessType,
external
)
}
private def stringValue(
attrs: Map[String, AttributeValue],
attrName: String
) =
attrValue(attrs, attrName, v => Some(v.s()))
private def longValue(attrs: Map[String, AttributeValue], attrName: String) =
attrValue(attrs, attrName, v => Try(v.n().toLong).toOption)
private def attrValue[A](
attrs: Map[String, AttributeValue],
attrName: String,
result: AttributeValue => Option[A]
) =
attrs
.find { case (name, _) => attrName == name }
.flatMap { case (_, value) => result(value) }
}