diff --git a/cmd/controller/app/start.go b/cmd/controller/app/start.go index b81c444..3fa9984 100644 --- a/cmd/controller/app/start.go +++ b/cmd/controller/app/start.go @@ -18,7 +18,6 @@ import ( "github.com/oursky/pageship/internal/handler/site" "github.com/oursky/pageship/internal/handler/site/middleware" "github.com/oursky/pageship/internal/httputil" - "github.com/oursky/pageship/internal/models" sitedb "github.com/oursky/pageship/internal/site/db" "github.com/oursky/pageship/internal/storage" "github.com/oursky/pageship/internal/watch" @@ -54,7 +53,7 @@ func init() { startCmd.PersistentFlags().String("host-pattern", config.DefaultHostPattern, "host match pattern") startCmd.PersistentFlags().String("host-id-scheme", string(config.HostIDSchemeDefault), "host ID scheme") startCmd.PersistentFlags().StringSlice("reserved-apps", []string{defaultControllerHostID}, "reserved app IDs") - startCmd.PersistentFlags().String("user-credentials-allowlist", "", "user credentials allowlist file") + startCmd.PersistentFlags().String("api-acl", "", "API ACL file") startCmd.PersistentFlags().String("token-authority", "pageship", "auth token authority") startCmd.PersistentFlags().String("token-signing-key", "", "auth token signing key") @@ -95,12 +94,12 @@ type StartSitesConfig struct { } type StartControllerConfig struct { - MaxDeploymentSize string `mapstructure:"max-deployment-size" validate:"size"` - StorageKeyPrefix string `mapstructure:"storage-key-prefix"` - TokenSigningKey string `mapstructure:"token-signing-key"` - TokenAuthority string `mapstructure:"token-authority"` - ReservedApps []string `mapstructure:"reserved-apps"` - UserCredentialsAllowlist string `mapstructure:"user-credentials-allowlist" validate:"omitempty,filepath"` + MaxDeploymentSize string `mapstructure:"max-deployment-size" validate:"size"` + StorageKeyPrefix string `mapstructure:"storage-key-prefix"` + TokenSigningKey string `mapstructure:"token-signing-key"` + TokenAuthority string `mapstructure:"token-authority"` + ReservedApps []string `mapstructure:"reserved-apps"` + APIACLFile string `mapstructure:"api-acl" validate:"omitempty,filepath"` } type StartCronConfig struct { @@ -175,24 +174,24 @@ func (s *setup) controller(domain string, conf StartControllerConfig, sitesConf TokenAuthority: conf.TokenAuthority, } - if conf.UserCredentialsAllowlist != "" { - allowlistLog := logger.Named("allowlist") - list, err := watch.NewFile( - allowlistLog, - conf.UserCredentialsAllowlist, - func(path string) (config.Allowlist[models.CredentialID], error) { + if conf.APIACLFile != "" { + aclLog := logger.Named("api-acl") + acl, err := watch.NewFile( + aclLog, + conf.APIACLFile, + func(path string) (config.ACL, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() - list, err := config.LoadAllowlist[models.CredentialID](f) + list, err := config.LoadACL(f) if err != nil { return nil, err } - allowlistLog.Info("loaded allowlist", zap.Int("count", len(list))) + aclLog.Info("loaded ACL", zap.Int("count", len(list))) return list, nil }, ) @@ -200,10 +199,10 @@ func (s *setup) controller(domain string, conf StartControllerConfig, sitesConf return err } - controllerConf.UserCredentialsAllowlist = list + controllerConf.ACL = acl s.works = append(s.works, func(ctx context.Context) error { <-ctx.Done() - list.Close() + acl.Close() return nil }) } diff --git a/internal/config/acl.go b/internal/config/acl.go new file mode 100644 index 0000000..208e5ce --- /dev/null +++ b/internal/config/acl.go @@ -0,0 +1,52 @@ +package config + +import ( + "fmt" + "io" + + "github.com/mitchellh/mapstructure" + "github.com/pelletier/go-toml/v2" +) + +type ACL []ACLSubjectRule + +func LoadACL(r io.Reader) (ACL, error) { + var m map[string]any + if err := toml.NewDecoder(r).Decode(&m); err != nil { + return nil, err + } + + var aclFile struct { + Access ACL `json:"access"` + } + if err := mapstructure.Decode(m, &aclFile); err != nil { + return nil, err + } + + if err := validate.Struct(aclFile); err != nil { + return nil, err + } + + return aclFile.Access, nil +} + +type ACLSubjectRule struct { + PageshipUser string `json:"pageshipUser,omitempty" pageship:"max=100"` + GitHubUser string `json:"githubUser,omitempty" pageship:"max=100"` + GitHubRepositoryActions string `json:"gitHubRepositoryActions,omitempty" pageship:"max=100"` + IpRange string `json:"ipRange,omitempty" pageship:"omitempty,max=100,cidr"` +} + +func (c *ACLSubjectRule) String() string { + switch { + case c.PageshipUser != "": + return fmt.Sprintf("pageshipUser:%s", c.PageshipUser) + case c.GitHubUser != "": + return fmt.Sprintf("githubUser:%s", c.GitHubUser) + case c.GitHubRepositoryActions != "": + return fmt.Sprintf("gitHubRepositoryActions:%s", c.GitHubRepositoryActions) + case c.IpRange != "": + return fmt.Sprintf("ipRange:%s", c.IpRange) + } + return "" +} diff --git a/internal/config/allowlist.go b/internal/config/allowlist.go deleted file mode 100644 index 1de6553..0000000 --- a/internal/config/allowlist.go +++ /dev/null @@ -1,39 +0,0 @@ -package config - -import ( - "bufio" - "io" - "strings" -) - -type Allowlist[T ~string] map[T]struct{} - -func LoadAllowlist[T ~string](r io.Reader) (Allowlist[T], error) { - scn := bufio.NewScanner(r) - list := make(map[T]struct{}) - for scn.Scan() { - line := scn.Text() - - if i := strings.Index(line, "#"); i != -1 { - // Trim comments. - line = line[:i] - } - - line = strings.TrimSpace(line) - if line == "" { - continue - } - - list[T(line)] = struct{}{} - } - return list, scn.Err() -} - -func (l Allowlist[T]) IsAllowed(values ...T) bool { - for _, v := range values { - if _, ok := l[v]; ok { - return true - } - } - return false -} diff --git a/internal/config/app_access.go b/internal/config/app_access.go index ef21a2d..6048d57 100644 --- a/internal/config/app_access.go +++ b/internal/config/app_access.go @@ -1,9 +1,5 @@ package config -import ( - "fmt" -) - type AccessLevel string const ( @@ -37,8 +33,8 @@ func (l AccessLevel) CanAccess(a AccessLevel) bool { } type AccessRule struct { - CredentialMatcher `mapstructure:",squash"` - Access AccessLevel `json:"access" pageship:"omitempty,accessLevel"` + ACLSubjectRule `mapstructure:",squash"` + Access AccessLevel `json:"access" pageship:"omitempty,accessLevel"` } func (r *AccessRule) SetDefaults() { @@ -46,24 +42,3 @@ func (r *AccessRule) SetDefaults() { r.Access = AccessLevelDefault } } - -type CredentialMatcher struct { - PageshipUser string `json:"pageshipUser,omitempty" pageship:"max=100"` - GitHubUser string `json:"githubUser,omitempty" pageship:"max=100"` - GitHubRepositoryActions string `json:"gitHubRepositoryActions,omitempty" pageship:"max=100"` - IpRange string `json:"ipRange,omitempty" pageship:"omitempty,max=100,cidr"` -} - -func (c *CredentialMatcher) String() string { - switch { - case c.PageshipUser != "": - return fmt.Sprintf("pageshipUser:%s", c.PageshipUser) - case c.GitHubUser != "": - return fmt.Sprintf("githubUser:%s", c.GitHubUser) - case c.GitHubRepositoryActions != "": - return fmt.Sprintf("gitHubRepositoryActions:%s", c.GitHubRepositoryActions) - case c.IpRange != "": - return fmt.Sprintf("ipRange:%s", c.IpRange) - } - return "" -} diff --git a/internal/config/app_deployment.go b/internal/config/app_deployment.go index 17e7013..20fcc8b 100644 --- a/internal/config/app_deployment.go +++ b/internal/config/app_deployment.go @@ -1,8 +1,8 @@ package config type AppDeploymentsConfig struct { - Access []CredentialMatcher `json:"access" pageship:"omitempty"` - TTL string `json:"ttl" pageship:"omitempty,duration"` + Access ACL `json:"access" pageship:"omitempty"` + TTL string `json:"ttl" pageship:"omitempty,duration"` } func (c *AppDeploymentsConfig) SetDefaults() { diff --git a/internal/config/site.go b/internal/config/site.go index 892ee8b..e90ac66 100644 --- a/internal/config/site.go +++ b/internal/config/site.go @@ -5,8 +5,8 @@ const SiteConfigName = "pageship" const DefaultSite = "main" type SiteConfig struct { - Public string `json:"public" pageship:"required"` - Access []CredentialMatcher `json:"access" pageship:"omitempty"` + Public string `json:"public" pageship:"required"` + Access ACL `json:"access" pageship:"omitempty"` } func DefaultSiteConfig() SiteConfig { diff --git a/internal/handler/controller/auth_github_ssh.go b/internal/handler/controller/auth_github_ssh.go index f298164..b3621e9 100644 --- a/internal/handler/controller/auth_github_ssh.go +++ b/internal/handler/controller/auth_github_ssh.go @@ -55,15 +55,15 @@ func (c *Controller) handleAuthGithubSSHConn(conn *websocket.Conn) { return nil, fmt.Errorf("unknown public key for %q", meta.User()) } - if c.Config.UserCredentialsAllowlist != nil { - list, err := c.Config.UserCredentialsAllowlist.Get(conn.Request().Context()) + if c.Config.ACL != nil { + acl, err := c.Config.ACL.Get(conn.Request().Context()) if err != nil { return nil, fmt.Errorf("access denied") } creds := []models.CredentialID{models.CredentialGitHubUser(meta.User())} creds = appendRequestCredentials(conn.Request(), creds) - if !list.IsAllowed(creds...) { + if _, err := models.CheckACLAuthz(acl, creds); err != nil { log(conn.Request()).Info( "user rejected", zap.String("github_user", meta.User()), diff --git a/internal/handler/controller/config.go b/internal/handler/controller/config.go index 72acba2..8c4b1af 100644 --- a/internal/handler/controller/config.go +++ b/internal/handler/controller/config.go @@ -2,17 +2,16 @@ package controller import ( "github.com/oursky/pageship/internal/config" - "github.com/oursky/pageship/internal/models" "github.com/oursky/pageship/internal/watch" ) type Config struct { - MaxDeploymentSize int64 - StorageKeyPrefix string - HostIDScheme config.HostIDScheme - HostPattern *config.HostPattern - ReservedApps map[string]struct{} - TokenAuthority string - TokenSigningKey []byte - UserCredentialsAllowlist *watch.File[config.Allowlist[models.CredentialID]] + MaxDeploymentSize int64 + StorageKeyPrefix string + HostIDScheme config.HostIDScheme + HostPattern *config.HostPattern + ReservedApps map[string]struct{} + TokenAuthority string + TokenSigningKey []byte + ACL *watch.File[config.ACL] } diff --git a/internal/handler/site/handler.go b/internal/handler/site/handler.go index 21786b7..d2ade45 100644 --- a/internal/handler/site/handler.go +++ b/internal/handler/site/handler.go @@ -98,7 +98,7 @@ func (h *Handler) checkAuthz(r *http.Request, handler *SiteHandler) error { credentials = append(credentials, models.CredentialIP(ip)) } - _, err = models.CheckDeploymentAuthz(access, credentials) + _, err = models.CheckACLAuthz(access, credentials) return err } diff --git a/internal/models/app.go b/internal/models/app.go index 2408f4d..095de93 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -34,9 +34,9 @@ func NewApp(now time.Time, id string, ownerUserID string) *App { func (a *App) CredentialIndexKeys() []CredentialIndexKey { m := make(map[CredentialIndexKey]struct{}) - collectIndexKeys(m, &config.CredentialMatcher{PageshipUser: a.OwnerUserID}) + collectIndexKeys(m, &config.ACLSubjectRule{PageshipUser: a.OwnerUserID}) for _, r := range a.Config.Team { - collectIndexKeys(m, &r.CredentialMatcher) + collectIndexKeys(m, &r.ACLSubjectRule) } var keys []CredentialIndexKey @@ -47,8 +47,8 @@ func (a *App) CredentialIndexKeys() []CredentialIndexKey { return keys } -func collectIndexKeys(keys map[CredentialIndexKey]struct{}, m *config.CredentialMatcher) { - for _, k := range MakeCredentialMatcherIndexKeys(m) { +func collectIndexKeys(keys map[CredentialIndexKey]struct{}, r *config.ACLSubjectRule) { + for _, k := range MakeCredentialRuleIndexKeys(r) { keys[k] = struct{}{} } } diff --git a/internal/models/app_authz.go b/internal/models/app_authz.go index d11626a..9e953cf 100644 --- a/internal/models/app_authz.go +++ b/internal/models/app_authz.go @@ -4,30 +4,30 @@ import "github.com/oursky/pageship/internal/config" type AppAuthzResult struct { CredentialID CredentialID - Matcher *config.CredentialMatcher // nil => is owner + Rule *config.ACLSubjectRule // nil => is owner } func (i *AppAuthzResult) MatchedRule() string { - if i.Matcher == nil { + if i.Rule == nil { return "" } - return i.Matcher.String() + return i.Rule.String() } func (a *App) CheckAuthz(level config.AccessLevel, userID string, credentials []CredentialID) (*AppAuthzResult, error) { if userID != "" && a.OwnerUserID == userID { return &AppAuthzResult{ CredentialID: CredentialUserID(a.OwnerUserID), - Matcher: nil, + Rule: nil, }, nil } for _, r := range a.Config.Team { for _, id := range credentials { - if id.Matches(&r.CredentialMatcher) && r.Access.CanAccess(level) { + if id.Matches(&r.ACLSubjectRule) && r.Access.CanAccess(level) { return &AppAuthzResult{ CredentialID: id, - Matcher: &r.CredentialMatcher, + Rule: &r.ACLSubjectRule, }, nil } } @@ -36,13 +36,13 @@ func (a *App) CheckAuthz(level config.AccessLevel, userID string, credentials [] return nil, ErrAccessDenied } -func CheckDeploymentAuthz(access []config.CredentialMatcher, credentials []CredentialID) (*AppAuthzResult, error) { - for _, m := range access { +func CheckACLAuthz(access config.ACL, credentials []CredentialID) (*AppAuthzResult, error) { + for _, r := range access { for _, id := range credentials { - if id.Matches(&m) { + if id.Matches(&r) { return &AppAuthzResult{ CredentialID: id, - Matcher: &m, + Rule: &r, }, nil } } diff --git a/internal/models/credential_id.go b/internal/models/credential_id.go index 1247736..4523c0b 100644 --- a/internal/models/credential_id.go +++ b/internal/models/credential_id.go @@ -34,7 +34,7 @@ func CredentialIP(ip string) CredentialID { return CredentialID(string(CredentialIDIP) + ":" + ip) } -func (c CredentialID) Matches(m *config.CredentialMatcher) bool { +func (c CredentialID) Matches(r *config.ACLSubjectRule) bool { kind, data, found := strings.Cut(string(c), ":") if !found { data = kind @@ -43,17 +43,17 @@ func (c CredentialID) Matches(m *config.CredentialMatcher) bool { switch CredentialIDKind(kind) { case CredentialIDKindUserID: - return m.PageshipUser != "" && m.PageshipUser == data + return r.PageshipUser != "" && r.PageshipUser == data case CredentialIDKindGitHubUser: - return m.GitHubUser != "" && m.GitHubUser == data + return r.GitHubUser != "" && r.GitHubUser == data case CredentialIDGitHubRepositoryActions: - return m.GitHubRepositoryActions != "" && m.GitHubRepositoryActions == data + return r.GitHubRepositoryActions != "" && r.GitHubRepositoryActions == data case CredentialIDIP: - if m.IpRange == "" { + if r.IpRange == "" { return false } - cidr, err := netip.ParsePrefix(m.IpRange) + cidr, err := netip.ParsePrefix(r.IpRange) if err != nil { return false } @@ -111,16 +111,16 @@ func CollectCredentialIDIndexKeys(ids []CredentialID) []CredentialIndexKey { return keys } -func MakeCredentialMatcherIndexKeys(c *config.CredentialMatcher) []CredentialIndexKey { +func MakeCredentialRuleIndexKeys(r *config.ACLSubjectRule) []CredentialIndexKey { switch { - case c.PageshipUser != "": - return MakeCredentialIDIndexKeys(CredentialUserID(c.PageshipUser)) - case c.GitHubUser != "": - return MakeCredentialIDIndexKeys(CredentialGitHubUser(c.GitHubUser)) - case c.GitHubRepositoryActions != "": - return MakeCredentialIDIndexKeys(CredentialGitHubRepositoryActions(c.GitHubRepositoryActions)) - case c.IpRange != "": - cidr, err := netip.ParsePrefix(c.IpRange) + case r.PageshipUser != "": + return MakeCredentialIDIndexKeys(CredentialUserID(r.PageshipUser)) + case r.GitHubUser != "": + return MakeCredentialIDIndexKeys(CredentialGitHubUser(r.GitHubUser)) + case r.GitHubRepositoryActions != "": + return MakeCredentialIDIndexKeys(CredentialGitHubRepositoryActions(r.GitHubRepositoryActions)) + case r.IpRange != "": + cidr, err := netip.ParsePrefix(r.IpRange) if err != nil { return nil }