diff --git a/internal/dis/component.go b/internal/dis/component.go index a5500fc..b94923a 100644 --- a/internal/dis/component.go +++ b/internal/dis/component.go @@ -20,6 +20,7 @@ func (dis *Distillery) init() { lazy.RegisterPoolGroup[component.Provisionable](&dis.pool) lazy.RegisterPoolGroup[component.Routeable](&dis.pool) lazy.RegisterPoolGroup[component.Cronable](&dis.pool) + lazy.RegisterPoolGroup[component.UserDeleteHook](&dis.pool) }) } diff --git a/internal/dis/component/auth/auth.go b/internal/dis/component/auth/auth.go index e7f5243..5f10383 100644 --- a/internal/dis/component/auth/auth.go +++ b/internal/dis/component/auth/auth.go @@ -15,7 +15,8 @@ import ( type Auth struct { component.Base Dependencies struct { - SQL *sql.SQL + SQL *sql.SQL + UserDeleteHooks []component.UserDeleteHook } store lazy.Lazy[sessions.Store] diff --git a/internal/dis/component/auth/permission.go b/internal/dis/component/auth/permission.go new file mode 100644 index 0000000..c279958 --- /dev/null +++ b/internal/dis/component/auth/permission.go @@ -0,0 +1,70 @@ +package auth + +import ( + "errors" + "net/http" +) + +// Permission represents a permission granted to a user. +// +// The nil permission represents any authenticated user. +type Permission func(user *AuthUser, r *http.Request) (ok Grant, err error) + +// Grant represents an object that either grants or denies access for a certain permission +type Grant interface { + isGranted() + + // Granted returns a boolean indicating if permission to the resource in question + // has been granted + Granted() bool + + // Denied returns a string containing an error message to display to the user when permission is denied. + // When Granted() returns true, the behaviour is undefined. + Denied() string +} + +// Bool2Grant returns a new grant that returns granted for the given boolean, and message as the denied message. +func Bool2Grant(granted bool, message string) Grant { + if granted { + return grantAllow{} + } + return grantDeny(message) +} + +type grantAllow struct{} + +func (grantAllow) isGranted() {} +func (grantAllow) Granted() bool { return true } +func (grantAllow) Denied() string { return "" } + +type grantDeny string + +func (grantDeny) isGranted() {} +func (g grantDeny) Granted() bool { return false } +func (g grantDeny) Denied() string { + if g == "" { + return "Forbidden" + } + return string(g) +} + +var errPermissionPanic = errors.New("permission: panic()") + +// Permit checks if the given user has this permission. +func (perm Permission) Permit(user *AuthUser, r *http.Request) (ok Grant, err error) { + // if there is no permission, then we just check if there is some user + if perm == nil { + return Bool2Grant(user != nil, ""), nil + } + + // recover any panic()ed permission call + // to prevent the handler from panic()ing + defer func() { + if p := recover(); p != nil { + ok = Bool2Grant(false, "unknown error") + err = errPermissionPanic + } + }() + + return perm(user, r) +} diff --git a/internal/dis/component/auth/policy/grants.go b/internal/dis/component/auth/policy/grants.go new file mode 100644 index 0000000..919164f --- /dev/null +++ b/internal/dis/component/auth/policy/grants.go @@ -0,0 +1,123 @@ +package policy + +import ( + "context" + "errors" + + "github.com/FAU-CDI/wisski-distillery/internal/models" + "gorm.io/gorm/clause" +) + +var ( + ErrNoAccess = errors.New("no access") + ErrInvalid = errors.New("invalid parameters") +) + +// Set sets a specific grant, overwriting a previous grant (if any) +func (policy *Policy) Set(ctx context.Context, grant models.Grant) error { + if grant.User == "" || grant.Slug == "" || grant.DrupalUsername == "" { + return ErrInvalid + } + + // get the table + table, err := policy.table(ctx) + if err != nil { + return err + } + + // and create or update the given user / slug combination + return table.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user"}, {Name: "slug"}}, + DoUpdates: clause.AssignmentColumns([]string{"drupal_user", "admin"}), + }).Create(&grant).Error +} + +// Remove removes access for the given username form the given instance. +// The user not having access is not an error. +func (policy *Policy) Remove(ctx context.Context, username string, slug string) error { + // empty username or slug never have acccess + if username == "" || slug == "" { + return ErrInvalid + } + + // get the table + table, err := policy.table(ctx) + if err != nil { + return err + } + + // delete the access from the database + return table.Delete(&models.Grant{}, models.Grant{User: username, Slug: slug}).Error +} + +// User returns all grants for the given user +func (policy *Policy) User(ctx context.Context, username string) (grants []models.Grant, err error) { + if username == "" { + return nil, ErrInvalid + } + + // get the table + table, err := policy.table(ctx) + if err != nil { + return nil, err + } + + // find the grants + err = table.Find(&grants, models.Grant{User: username}).Error + if err != nil { + return nil, err + } + return grants, nil +} + +// Instance returns all the grants for the given instance +func (policy *Policy) Instance(ctx context.Context, slug string) (grants []models.Grant, err error) { + if slug == "" { + return nil, ErrInvalid + } + + // get the table + table, err := policy.table(ctx) + if err != nil { + return nil, err + } + + // find the grants + err = table.Find(&grants, models.Grant{Slug: slug}).Error + if err != nil { + return nil, err + } + return grants, nil +} + +// Has checks if the given username has access to the given instance. +// If the user has access, returns the provided grant. +// +// If the user does not have access, returns ErrNoAccess. +// Other errors may be returned in other cases. +func (policy *Policy) Has(ctx context.Context, username string, slug string) (grant models.Grant, err error) { + // empty username or slug never have acccess + if username == "" || slug == "" { + return grant, ErrInvalid + } + + // get the table + table, err := policy.table(ctx) + if err != nil { + return grant, err + } + + // read the access from the database + res := table.Find(&grant, models.Grant{User: username, Slug: slug}) + if err := res.Error; err != nil { + return grant, err + } + + // if there were no rows affected, then there was no access granted + if res.RowsAffected == 0 { + return grant, ErrNoAccess + } + + // return the username and admin + return grant, nil +} diff --git a/internal/dis/component/auth/policy/info.go b/internal/dis/component/auth/policy/info.go new file mode 100644 index 0000000..8cbf7ee --- /dev/null +++ b/internal/dis/component/auth/policy/info.go @@ -0,0 +1 @@ +package policy diff --git a/internal/dis/component/auth/policy/policy.go b/internal/dis/component/auth/policy/policy.go new file mode 100644 index 0000000..53cbd19 --- /dev/null +++ b/internal/dis/component/auth/policy/policy.go @@ -0,0 +1,27 @@ +package policy + +import ( + "context" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" + "github.com/FAU-CDI/wisski-distillery/internal/models" + "gorm.io/gorm" +) + +type Policy struct { + component.Base + + Dependencies struct { + SQL *sql.SQL + } +} + +var ( + _ component.Provisionable = (*Policy)(nil) + _ component.UserDeleteHook = (*Policy)(nil) +) + +func (pol *Policy) table(ctx context.Context) (*gorm.DB, error) { + return pol.Dependencies.SQL.QueryTable(ctx, true, models.GrantTable) +} diff --git a/internal/dis/component/auth/policy/purge.go b/internal/dis/component/auth/policy/purge.go new file mode 100644 index 0000000..5f687b0 --- /dev/null +++ b/internal/dis/component/auth/policy/purge.go @@ -0,0 +1,30 @@ +package policy + +import ( + "context" + + "github.com/FAU-CDI/wisski-distillery/internal/models" +) + +func (*Policy) Provision(ctx context.Context, instance models.Instance, domain string) error { + // component is purge-only + return nil +} + +// Purge purges every policy for the given slug form the database +func (pol *Policy) Purge(ctx context.Context, instance models.Instance, domain string) error { + table, err := pol.table(ctx) + if err != nil { + return err + } + return table.Delete(&models.Grant{}, &models.Grant{Slug: instance.Slug}).Error +} + +// OnUserDelete is called when a user is deleted +func (pol *Policy) OnUserDelete(ctx context.Context, user *models.User) error { + table, err := pol.table(ctx) + if err != nil { + return err + } + return table.Delete(&models.Grant{}, &models.Grant{User: user.User}).Error +} diff --git a/internal/dis/component/auth/protect.go b/internal/dis/component/auth/protect.go index 2736580..12d20f2 100644 --- a/internal/dis/component/auth/protect.go +++ b/internal/dis/component/auth/protect.go @@ -2,77 +2,12 @@ package auth import ( "context" - "errors" "net/http" "net/url" "github.com/FAU-CDI/wisski-distillery/pkg/httpx" ) -// Permission represents a permission granted to a user. -// -// The nil permission represents any authenticated user. -type Permission func(user *AuthUser, r *http.Request) (ok Grant, err error) - -// Grant represents an object that either grants or denies access for a certain permission -type Grant interface { - isGranted() - - // Granted returns a boolean indicating if permission to the resource in question - // has been granted - Granted() bool - - // Denied returns a string containing an error message to display to the user when permission is denied. - // When Granted() returns true, the behaviour is undefined. - Denied() string -} - -// Bool2Grant returns a new grant that returns granted for the given boolean, and message as the denied message. -func Bool2Grant(granted bool, message string) Grant { - if granted { - return grantAllow{} - } - return grantDeny(message) -} - -type grantAllow struct{} - -func (grantAllow) isGranted() {} -func (grantAllow) Granted() bool { return true } -func (grantAllow) Denied() string { return "" } - -type grantDeny string - -func (grantDeny) isGranted() {} -func (g grantDeny) Granted() bool { return false } -func (g grantDeny) Denied() string { - if g == "" { - return "Forbidden" - } - return string(g) -} - -var errPermissionPanic = errors.New("permission: panic()") - -// Permit checks if the given user has this permission. -func (perm Permission) Permit(user *AuthUser, r *http.Request) (ok Grant, err error) { - // if there is no permission, then we just check if there is some user - if perm == nil { - return Bool2Grant(user != nil, ""), nil - } - - // recover any panic()ed permission call - // to prevent the handler from panic()ing - defer func() { - if p := recover(); p != nil { - ok = Bool2Grant(false, "unknown error") - err = errPermissionPanic - } - }() - - return perm(user, r) -} - // Protect returns a new handler which requires a user to be logged in and pass the perm function. // // If an unauthenticated user attempts to access the returned handler, they are redirected to the login endpoint. diff --git a/internal/dis/component/auth/user.go b/internal/dis/component/auth/user.go index 8217867..d6b5b6a 100644 --- a/internal/dis/component/auth/user.go +++ b/internal/dis/component/auth/user.go @@ -278,5 +278,13 @@ func (au *AuthUser) Delete(ctx context.Context) error { if err != nil { return err } + + // run all the user delete hooks + for _, c := range au.auth.Dependencies.UserDeleteHooks { + if err := c.OnUserDelete(ctx, &au.User); err != nil { + return err + } + } + return table.Delete(&au.User).Error } diff --git a/internal/dis/component/instances/malt/malt.go b/internal/dis/component/instances/malt/malt.go index 5aa5009..dbefd37 100644 --- a/internal/dis/component/instances/malt/malt.go +++ b/internal/dis/component/instances/malt/malt.go @@ -2,6 +2,7 @@ package malt import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/meta" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" @@ -16,4 +17,5 @@ type Malt struct { SQL *sql.SQL `auto:"true"` Meta *meta.Meta `auto:"true"` ExporterLog *logger.Logger `auto:"true"` + Policy *policy.Policy `auto:"true"` } diff --git a/internal/dis/component/meta/storage.go b/internal/dis/component/meta/storage.go index 65c0e76..3c21d63 100644 --- a/internal/dis/component/meta/storage.go +++ b/internal/dis/component/meta/storage.go @@ -26,7 +26,7 @@ type Storage struct { // Get retrieves metadata with the provided key and deserializes the first one into target. // If no metadatum exists, returns [ErrMetadatumNotSet]. func (s Storage) Get(ctx context.Context, key Key, target any) error { - table, err := s.sql.QueryTable(ctx, true, models.AccessTable) + table, err := s.sql.QueryTable(ctx, true, models.GrantTable) if err != nil { return err } @@ -55,7 +55,7 @@ func (s Storage) Get(ctx context.Context, key Key, target any) error { // // When no metadatum exists, targets is not called, and nil error is returned. func (s Storage) GetAll(ctx context.Context, key Key, target func(index, total int) any) error { - table, err := s.sql.QueryTable(ctx, true, models.AccessTable) + table, err := s.sql.QueryTable(ctx, true, models.GrantTable) if err != nil { return err } @@ -82,7 +82,7 @@ func (s Storage) GetAll(ctx context.Context, key Key, target func(index, total i // Delete deletes all metadata with the provided key. func (s Storage) Delete(ctx context.Context, key Key) error { - table, err := s.sql.QueryTable(ctx, true, models.AccessTable) + table, err := s.sql.QueryTable(ctx, true, models.GrantTable) if err != nil { return err } @@ -98,7 +98,7 @@ func (s Storage) Delete(ctx context.Context, key Key) error { // Set serializes value and stores it with the provided key. // Any other metadata with the same key is deleted. func (s Storage) Set(ctx context.Context, key Key, value any) error { - table, err := s.sql.QueryTable(ctx, true, models.AccessTable) + table, err := s.sql.QueryTable(ctx, true, models.GrantTable) if err != nil { return err } @@ -133,7 +133,7 @@ func (s Storage) Set(ctx context.Context, key Key, value any) error { // Set serializes values and stores them with the provided key. // Any other metadata with the same key is deleted. func (s Storage) SetAll(ctx context.Context, key Key, values ...any) error { - table, err := s.sql.QueryTable(ctx, true, models.AccessTable) + table, err := s.sql.QueryTable(ctx, true, models.GrantTable) if err != nil { return err } @@ -167,7 +167,7 @@ func (s Storage) SetAll(ctx context.Context, key Key, values ...any) error { // Purge removes all metadata, regardless of key. func (s Storage) Purge(ctx context.Context) error { - table, err := s.sql.QueryTable(ctx, true, models.AccessTable) + table, err := s.sql.QueryTable(ctx, true, models.GrantTable) if err != nil { return err } diff --git a/internal/dis/component/sql/update.go b/internal/dis/component/sql/update.go index a85ed7f..c930709 100644 --- a/internal/dis/component/sql/update.go +++ b/internal/dis/component/sql/update.go @@ -91,7 +91,7 @@ func (sql *SQL) Update(ctx context.Context, progress io.Writer) error { { "metadata", &models.Metadatum{}, - models.AccessTable, + models.GrantTable, }, { "snapshot", @@ -108,6 +108,11 @@ func (sql *SQL) Update(ctx context.Context, progress io.Writer) error { &models.User{}, models.UserTable, }, + { + "grant", + &models.Grant{}, + models.GrantTable, + }, } // migrate all of the tables! diff --git a/internal/dis/component/userdelete.go b/internal/dis/component/userdelete.go new file mode 100644 index 0000000..4f23137 --- /dev/null +++ b/internal/dis/component/userdelete.go @@ -0,0 +1,15 @@ +package component + +import ( + "context" + + "github.com/FAU-CDI/wisski-distillery/internal/models" +) + +// UserDeleteHook represents a hook that is called just before a user is deleted +type UserDeleteHook interface { + Component + + // OnUserDelete is called right before a user is deleted + OnUserDelete(ctx context.Context, user *models.User) error +} diff --git a/internal/dis/distillery.go b/internal/dis/distillery.go index 932f122..9db97ab 100644 --- a/internal/dis/distillery.go +++ b/internal/dis/distillery.go @@ -8,6 +8,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/cron" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/home" @@ -127,6 +128,7 @@ func (dis *Distillery) allComponents() []initFunc { // auth auto[*auth.Auth], + auto[*policy.Policy], // instances auto[*instances.Instances], diff --git a/internal/models/grant.go b/internal/models/grant.go new file mode 100644 index 0000000..6daf423 --- /dev/null +++ b/internal/models/grant.go @@ -0,0 +1,15 @@ +package models + +// GrantTable is the name of the table the 'Grant' model is stored in. +const GrantTable = "grant" + +// Grant represents an access grant to a specific user +type Grant struct { + Pk uint `gorm:"column:pk;primaryKey"` + + User string `gorm:"column:user;not null;uniqueIndex:user_slug"` // (distillery) username + Slug string `gorm:"column:slug;not null;uniqueIndex:user_slug"` // (distillery) instance slug + + DrupalUsername string `gorm:"column:drupal_user;not null"` // drupal username + DrupalAdminRole bool `gorm:"column:admin;not null"` // drupal admin rights +}