refactor: keep tenant applications out of the administrator's reach
Claiming the system administrator role no longer grants access to every application in the installation. Owning a tenant is what lets an administrator administer it; entering the applications running inside it is a separate, explicit grant that can be withdrawn again. Handing it out at claim time gave every installation a standing administrator account inside every customer application, which is the opposite of the boundary this product draws. The behaviour suite now covers single sign-on between applications directly: a second application on the same tenant must receive a code and identify the same person with no sign-in page in between, once Go Loose already holds a session. Also adds theme-token tests against the shared styling contract and store coverage for application grants, and drops the redundant server and security scheme declarations from the published specification.
This commit is contained in:
parent
8d8970d5a7
commit
49ab89ae46
6 changed files with 319 additions and 20 deletions
121
internal/store/application_access_integration_test.go
Normal file
121
internal/store/application_access_integration_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package store_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/danyel/go-loose/internal/store"
|
||||
)
|
||||
|
||||
// TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications pins the
|
||||
// boundary between the management plane and the client plane.
|
||||
//
|
||||
// Owning every tenant is what lets the first administrator reach the whole
|
||||
// installation from the console. It is not permission to sign in to the
|
||||
// applications running inside those tenants: ClientForAuthorization answers for a
|
||||
// user_application_access row and nothing else, so a claim that inserted one per
|
||||
// application would ship every installation with a standing administrator
|
||||
// account inside every customer application. Access to an application is an
|
||||
// explicit grant, and this test also pins that such a grant is still possible on
|
||||
// purpose, because an administrator is allowed to attach themselves.
|
||||
func TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications(t *testing.T) {
|
||||
s, _, ctx := integrationStore(t)
|
||||
|
||||
// Two tenants, each with a client configured, so that "every application" is
|
||||
// not satisfied by a single row.
|
||||
firstOwner := newUser(t, s, ctx, "first@example.test")
|
||||
first := newTenant(t, s, ctx, firstOwner, "nmbs")
|
||||
secondOwner := newUser(t, s, ctx, "second@example.test")
|
||||
second := newTenant(t, s, ctx, secondOwner, "ypto")
|
||||
|
||||
const redirect = "https://app.example.test/callback"
|
||||
application := func(owner, tenantID, slug string) store.Application {
|
||||
t.Helper()
|
||||
created, err := s.CreateApplication(ctx, owner, store.Application{
|
||||
TenantID: tenantID, Slug: slug, Name: slug, AllowedHosts: []string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create application %s: %v", slug, err)
|
||||
}
|
||||
if _, err := s.ConfigureClient(ctx, owner, created.ID, []string{redirect}, secretHash(slug)); err != nil {
|
||||
t.Fatalf("configure client login for %s: %v", slug, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
firstApp := application(firstOwner, first.ID, "guess")
|
||||
secondApp := application(secondOwner, second.ID, "tell")
|
||||
|
||||
admin := newUser(t, s, ctx, "admin@example.test")
|
||||
claimed, err := s.ClaimFirstSystemAdministrator(ctx, admin)
|
||||
if err != nil {
|
||||
t.Fatalf("claim first system administrator: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("the first user to sign in must become the system administrator")
|
||||
}
|
||||
|
||||
// The management plane still reaches everything: the claim owns both tenants,
|
||||
// which is what the console needs to administer them.
|
||||
console, err := s.HasConsoleAccess(ctx, admin)
|
||||
if err != nil {
|
||||
t.Fatalf("HasConsoleAccess: %v", err)
|
||||
}
|
||||
if !console {
|
||||
t.Error("a system administrator must have console access")
|
||||
}
|
||||
tenants, err := s.ListTenants(ctx, admin)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTenants: %v", err)
|
||||
}
|
||||
if len(tenants) != 2 {
|
||||
t.Errorf("the administrator owns %d tenants, want 2", len(tenants))
|
||||
}
|
||||
|
||||
// The client plane does not. No application was granted, so neither endpoint
|
||||
// that mints or resolves a hosted-login session will recognize them.
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
application store.Application
|
||||
}{
|
||||
{name: "nmbs", application: firstApp},
|
||||
{name: "ypto", application: secondApp},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := s.ClientForAuthorization(ctx, test.application.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("ClientForAuthorization = %v, want ErrNotFound: owning a tenant must not open its applications", err)
|
||||
}
|
||||
if _, err := s.ClientUserByToken(ctx, secretHash("no-session")); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("ClientUserByToken = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Attaching the administrator to one application is allowed, and it is the only
|
||||
// way in. They hold users.manage in every tenant by virtue of owning it, which
|
||||
// is what makes the deliberate act possible.
|
||||
ownerRole, err := s.SystemRoleID(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("look up the owner role: %v", err)
|
||||
}
|
||||
if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, []string{firstApp.ID}); err != nil {
|
||||
t.Fatalf("an administrator must be able to attach themselves to an application: %v", err)
|
||||
}
|
||||
if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); err != nil {
|
||||
t.Fatalf("ClientForAuthorization after an explicit grant = %v, want the application", err)
|
||||
}
|
||||
|
||||
// Withdrawing it takes effect immediately and leaves the console untouched.
|
||||
if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, nil); err != nil {
|
||||
t.Fatalf("withdraw the grant: %v", err)
|
||||
}
|
||||
if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("ClientForAuthorization after withdrawal = %v, want ErrNotFound", err)
|
||||
}
|
||||
console, err = s.HasConsoleAccess(ctx, admin)
|
||||
if err != nil {
|
||||
t.Fatalf("HasConsoleAccess: %v", err)
|
||||
}
|
||||
if !console {
|
||||
t.Error("withdrawing an application grant must not cost the administrator their console")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue