Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions charts/console-backend-rbac/templates/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,24 @@ rules:
- update
- patch
- delete
- apiGroups:
- "nais.io"
resources:
- postgres
verbs:
- get
- list
- watch
- create
- update
- apiGroups:
- "nais.io"
resources:
- postgresaccesses
verbs:
- get
- create
- delete
Comment thread
jhrv marked this conversation as resolved.
- apiGroups:
- "nais.io"
resources:
Expand Down Expand Up @@ -302,6 +313,7 @@ rules:
verbs:
- get
- create
- delete
Comment thread
jhrv marked this conversation as resolved.
- apiGroups:
- "nais.io"
resources:
Expand Down
81 changes: 71 additions & 10 deletions integration_tests/create_postgres_access.lua
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
local user = User.new("user", "user@usersen.com")
local otherMemberUser = User.new("othermember", "othermember@usersen.com")
local creatorUser = User.new("creator", "creator@usersen.com")
local staleUser = User.new("stale", "stale@usersen.com")
local nonMemberUser = User.new("nonmember", "other@user.com")

local mainTeam = Team.new("someteamname", "purpose", "#slack_channel")
mainTeam:addMember(user)
mainTeam:addMember(otherMemberUser)
mainTeam:addMember(creatorUser)
mainTeam:addMember(staleUser)

Helper.readK8sResources("k8s_resources/create_postgres_access")

Expand Down Expand Up @@ -151,7 +155,7 @@ Test.gql("Create personal postgres access rejects an unavailable instance", func
end)

Test.gql("Create personal postgres access", function(t)
t.addHeader("x-user-email", user:email())
t.addHeader("x-user-email", creatorUser:email())
t.query [[
mutation CreatePostgresAccess {
createPostgresAccess(input: {
Expand Down Expand Up @@ -179,7 +183,7 @@ Test.gql("Create personal postgres access", function(t)
end)

Test.gql("Personal postgres access is audited as a self-grant", function(t)
t.addHeader("x-user-email", user:email())
t.addHeader("x-user-email", creatorUser:email())
t.query [[
{
team(slug: "someteamname") {
Expand All @@ -206,9 +210,9 @@ Test.gql("Personal postgres access is audited as a self-grant", function(t)
activityLog = {
nodes = {
{
message = Contains("Requested READWRITE personal Postgres access for user@usersen.com"),
message = Contains("Requested READWRITE personal Postgres access for creator@usersen.com"),
data = {
username = "user@usersen.com",
username = "creator@usersen.com",
accessLevel = "READWRITE",
expiresAt = NotNull(),
reason = "Testing personal database access",
Expand All @@ -221,6 +225,38 @@ Test.gql("Personal postgres access is audited as a self-grant", function(t)
}
end)

Test.gql("Requesting access again returns the live access instead of creating another", function(t)
t.addHeader("x-user-email", creatorUser:email())
local request = [[
mutation { createPostgresAccess(input: {
postgres: "foobar", branch: "main", environmentName: "dev", teamSlug: "someteamname",
accessLevel: READWRITE, reason: "Testing personal database access"
}) { name expiresAt } }
]]
t.query(request)
t.check { data = { createPostgresAccess = { name = NotNull(), expiresAt = NotNull() } } }
t.query(request)
t.check { data = { createPostgresAccess = { name = NotNull(), expiresAt = NotNull() } } }

-- A reused access is not a new request, so it must not be audited again.
t.query [[{ team(slug: "someteamname") { activityLog(filter: { activityTypes: [POSTGRES_PERSONAL_ACCESS_CREATED] }) { nodes { message } } } }]]
t.check { data = { team = { activityLog = { nodes = { { message = Contains("creator@usersen.com") } } } } } }
end)

Test.gql("Requesting a different access level while one is live is rejected", function(t)
t.addHeader("x-user-email", creatorUser:email())
t.query [[
mutation { createPostgresAccess(input: {
postgres: "foobar", branch: "main", environmentName: "dev", teamSlug: "someteamname",
accessLevel: READ, reason: "Testing personal database access"
}) { name } }
]]
t.check {
errors = { { locations = NotNull(), path = { "createPostgresAccess" }, message = Contains("already have readwrite access") } },
data = Null,
}
end)

Test.gql("Personal access targets a physical instance, even when its name differs from logical Postgres", function(t)
t.addHeader("x-user-email", user:email())
t.query [[
Expand Down Expand Up @@ -340,24 +376,31 @@ Test.gql("PostgresAccess credentials cannot be read through generic Secret eleva
end
end)

Test.gql("PostgresAccess connection rejects expired, unready, and missing-secret access", function(t)
Test.gql("PostgresAccess connection is null without error until the access is ready", function(t)
t.addHeader("x-user-email", user:email())
for _, test in ipairs({
{ name = "expired-access", state = "EXPIRED", message = "has expired" },
{ name = "pending-access", state = "PENDING", message = "is not ready" },
{ name = "failed-access", state = "FAILED", message = "is not ready" },
{ name = "missing-secret-access", state = "READY", message = "secrets for PostgresAccess is not available" },
{ name = "expired-access", state = "EXPIRED" },
{ name = "pending-access", state = "PENDING" },
{ name = "failed-access", state = "FAILED" },
}) do
t.query(string.format(
[[query { team(slug: "someteamname") { environment(name: "dev") { postgresAccess(name: "%s") { state connection { password } } } } }]],
test.name))
t.check {
errors = { { locations = NotNull(), path = { "team", "environment", "postgresAccess", "connection" }, message = Contains(test.message) } },
data = { team = { environment = { postgresAccess = { state = test.state, connection = Null } } } },
}
end
end)

Test.gql("PostgresAccess connection fails when a ready access has no secrets", function(t)
t.addHeader("x-user-email", user:email())
t.query [[query { team(slug: "someteamname") { environment(name: "dev") { postgresAccess(name: "missing-secret-access") { state connection { password } } } } }]]
t.check {
errors = { { locations = NotNull(), path = { "team", "environment", "postgresAccess", "connection" }, message = Contains("secrets for PostgresAccess is not available") } },
data = { team = { environment = { postgresAccess = { state = "READY", connection = Null } } } },
}
end)

Test.gql("Personal postgres connection retrieval is audited", function(t)
t.addHeader("x-user-email", user:email())
t.query [[
Expand All @@ -374,3 +417,21 @@ Test.gql("Personal postgres connection retrieval is audited", function(t)
} } } },
}
end)

Test.gql("An expired access is cleaned up, after which a new one can be requested", function(t)
t.addHeader("x-user-email", staleUser:email())
local request = [[
mutation { createPostgresAccess(input: {
postgres: "foobar", branch: "main", environmentName: "dev", teamSlug: "someteamname",
accessLevel: READ, reason: "Testing personal database access"
}) { name } }
]]
t.query(request)
t.check {
errors = { { locations = NotNull(), path = { "createPostgresAccess" }, message = Contains("being cleaned up") } },
data = Null,
}

t.query(request)
t.check { data = { createPostgresAccess = { name = "postgres-access-9269571e872b7c9d" } } }
end)
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,15 @@ status:
conditions:
- type: Ready
status: "True"
---
apiVersion: nais.io/v1
kind: PostgresAccess
metadata:
name: postgres-access-9269571e872b7c9d
namespace: someteamname
uid: 33333333-3333-4333-8333-333333333333
spec:
postgresBranch: foobar-main-a4f04c0c
username: stale@usersen.com
accessLevel: read
expiresAt: "2000-01-01T00:00:00Z"
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
apiVersion: nais.io/v1
kind: Postgres
metadata:
name: existing
namespace: pg-crud-team
spec:
majorVersion: "17"
highAvailability: false
resources:
cpu: 100m
memory: 512Mi
diskSize: 10Gi
177 changes: 177 additions & 0 deletions integration_tests/postgres_crud.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
local member = User.new("postgres-crud-user", "postgres-crud-user@usersen.com")
local outsider = User.new("postgres-crud-outsider", "postgres-crud-outsider@usersen.com")
local team = Team.new("pg-crud-team", "Testing Postgres create and update", "#postgres-crud")
team:addMember(member)
Helper.readK8sResources("k8s_resources/postgres_crud")

local function create(name, extra)
return string.format(
[[mutation { createPostgres(input: {
name: "%s", environmentName: "dev", teamSlug: "pg-crud-team", %s
}) { postgres { name majorVersion highAvailability resources { cpu memory diskSize } } } }]],
name,
extra
)
end

Test.gql("Non-members cannot create Postgres", function(t)
t.addHeader("x-user-email", outsider:email())
t.query(create("denied", 'majorVersion: "18"'))
t.check {
errors = { { locations = NotNull(), path = { "createPostgres" }, message = Contains("You are authenticated") } },
data = Null,
}
end)

Test.gql("Team members can create Postgres with defaults left to the platform", function(t)
t.addHeader("x-user-email", member:email())
t.query(create("fresh", 'majorVersion: "18"'))
t.check {
data = {
createPostgres = {
postgres = {
name = "fresh",
majorVersion = "18",
highAvailability = false,
resources = { cpu = Null, memory = Null, diskSize = Null },
},
},
},
}
end)

Test.gql("Team members can create Postgres with explicit resources", function(t)
t.addHeader("x-user-email", member:email())
t.query(create("sized", 'majorVersion: "18", highAvailability: true, cpu: "250m", memory: "1Gi", diskSize: "20Gi"'))
t.check {
data = {
createPostgres = {
postgres = {
name = "sized",
majorVersion = "18",
highAvailability = true,
resources = { cpu = "250m", memory = "1Gi", diskSize = "20Gi" },
},
},
},
}
end)

Test.gql("Creating an existing Postgres fails", function(t)
t.addHeader("x-user-email", member:email())
t.query(create("existing", 'majorVersion: "18"'))
t.check {
errors = { { locations = NotNull(), path = { "createPostgres" }, message = NotNull() } },
data = Null,
}
end)

Test.gql("Unsupported major version is rejected", function(t)
t.addHeader("x-user-email", member:email())
t.query(create("badversion", 'majorVersion: "9"'))
t.check {
errors = { { extensions = { field = "majorVersion" }, path = { "createPostgres" }, message = Contains("Major version must be one of") } },
data = Null,
}
end)

Test.gql("Invalid resource quantity is rejected", function(t)
t.addHeader("x-user-email", member:email())
t.query(create("badquantity", 'majorVersion: "18", cpu: "lots"'))
t.check {
errors = { { extensions = { field = "cpu" }, path = { "createPostgres" }, message = Contains("is not a valid quantity") } },
data = Null,
}
end)

Test.gql("Team members can update only the fields they provide", function(t)
t.addHeader("x-user-email", member:email())
t.query [[mutation { updatePostgres(input: {
name: "existing", environmentName: "dev", teamSlug: "pg-crud-team", highAvailability: true, diskSize: "30Gi"
}) { postgres { majorVersion highAvailability resources { cpu memory diskSize } } } }]]
t.check {
data = {
updatePostgres = {
postgres = {
majorVersion = "17",
highAvailability = true,
resources = { cpu = "100m", memory = "512Mi", diskSize = "30Gi" },
},
},
},
}
end)

Test.gql("Non-members cannot update Postgres", function(t)
t.addHeader("x-user-email", outsider:email())
t.query [[mutation { updatePostgres(input: {
name: "existing", environmentName: "dev", teamSlug: "pg-crud-team", highAvailability: true
}) { postgres { name } } }]]
t.check {
errors = { { locations = NotNull(), path = { "updatePostgres" }, message = Contains("You are authenticated") } },
data = Null,
}
end)

Test.gql("Updating a missing Postgres fails", function(t)
t.addHeader("x-user-email", member:email())
t.query [[mutation { updatePostgres(input: {
name: "missing", environmentName: "dev", teamSlug: "pg-crud-team", highAvailability: true
}) { postgres { name } } }]]
t.check {
errors = { { locations = NotNull(), path = { "updatePostgres" }, message = Contains("not found") } },
data = Null,
}
end)

Test.gql("Names that Kubernetes would reject are rejected as field errors", function(t)
t.addHeader("x-user-email", member:email())
for _, name in ipairs({ "Upper", "has/slash", "-leading" }) do
t.query(create(name, 'majorVersion: "18"'))
t.check {
errors = { { extensions = { field = "name" }, path = { "createPostgres" }, message = Contains("lowercase letters") } },
data = Null,
}
end
t.query [[mutation { updatePostgres(input: {
name: "Upper", environmentName: "dev", teamSlug: "pg-crud-team", highAvailability: true
}) { postgres { name } } }]]
t.check {
errors = { { extensions = { field = "name" }, path = { "updatePostgres" }, message = Contains("lowercase letters") } },
data = Null,
}
end)

Test.gql("Zero and negative resource quantities are rejected", function(t)
t.addHeader("x-user-email", member:email())
for _, field in ipairs({ "cpu", "memory", "diskSize" }) do
t.query(create("badquantity", string.format('majorVersion: "18", %s: "-1"', field)))
t.check {
errors = { { extensions = { field = field }, path = { "createPostgres" }, message = Contains("must be greater than zero") } },
data = Null,
}
end
end)

Test.gql("Creating and updating Postgres is recorded in the activity log", function(t)
t.addHeader("x-user-email", member:email())
t.query [[{ team(slug: "pg-crud-team") {
activityLog(first: 50, filter: { activityTypes: [POSTGRES_CREATED, POSTGRES_UPDATED] }) {
nodes { __typename message actor resourceType resourceName environmentName teamSlug
... on PostgresUpdatedActivityLogEntry { data { updatedFields { field oldValue newValue } } }
}
}
} }]]
t.check {
data = { team = { activityLog = { nodes = {
{ __typename = "PostgresUpdatedActivityLogEntry", message = "Updated Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = "existing", environmentName = "dev", teamSlug = "pg-crud-team",
data = { updatedFields = {
{ field = "highAvailability", oldValue = "false", newValue = "true" },
{ field = "diskSize", oldValue = "10Gi", newValue = "30Gi" },
} },
},
{ __typename = "PostgresCreatedActivityLogEntry", message = "Created Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = Ignore(), environmentName = "dev", teamSlug = "pg-crud-team" },
{ __typename = "PostgresCreatedActivityLogEntry", message = "Created Postgres", actor = member:email(), resourceType = "POSTGRES", resourceName = Ignore(), environmentName = "dev", teamSlug = "pg-crud-team" },
} } } },
}
end)
8 changes: 8 additions & 0 deletions internal/auth/authz/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,14 @@ func CanGrantPostgresAccess(ctx context.Context, teamSlug slug.Slug) error {
return requireStrictTeamAuthorization(ctx, teamSlug, "postgres:access:grant")
}

func CanCreatePostgres(ctx context.Context, teamSlug slug.Slug) error {
return requireTeamAuthorization(ctx, teamSlug, "postgres:create")
}

func CanUpdatePostgres(ctx context.Context, teamSlug slug.Slug) error {
return requireTeamAuthorization(ctx, teamSlug, "postgres:update")
}

func CanDeletePostgres(ctx context.Context, teamSlug slug.Slug) error {
return requireTeamAuthorization(ctx, teamSlug, "postgres:delete")
}
Expand Down
Loading
Loading