diff --git a/caveat.go b/caveat.go index 34bc97e..1581a09 100644 --- a/caveat.go +++ b/caveat.go @@ -35,7 +35,7 @@ const ( CavAuthConfineGoogleHD CavAuthConfineGitHubOrg CavAuthMaxValidity - CavNoAdminFeatures + CavFlyioIsMember AttestationAuthFlyioUserID AttestationAuthGitHubUserID AttestationAuthGoogleUserID @@ -43,6 +43,7 @@ const ( CavFlyioCommands CavFlyioAppFeatureSet CavFlyioStorageObjects + CavAllowedRoles // allocate internal blocks of size 255 here block255Min CaveatType = 1 << 16 diff --git a/flyio/access.go b/flyio/access.go index 9dfe692..ca70201 100644 --- a/flyio/access.go +++ b/flyio/access.go @@ -112,6 +112,69 @@ func (f *Access) Validate() error { return nil } +const ( + FeatureWireGuard = "wg" + FeatureDomains = "domain" + FeatureSites = "site" + FeatureRemoteBuilders = "builder" + FeatureAddOns = "addon" + FeatureChecks = "checks" + FeatureLFSC = "litefs-cloud" + FeatureMembership = "membership" + FeatureBilling = "billing" + FeatureDeletion = "deletion" + FeatureDocumentSigning = "document_signing" + FeatureAuthentication = "authentication" +) + +var ( + // MemberFeatures describes the level of access that non-admins are allowed + // for various org features. + MemberFeatures = map[string]resset.Action{ + FeatureWireGuard: resset.ActionAll, + FeatureDomains: resset.ActionAll, + FeatureSites: resset.ActionAll, + FeatureRemoteBuilders: resset.ActionAll, + FeatureAddOns: resset.ActionAll, + FeatureChecks: resset.ActionAll, + FeatureLFSC: resset.ActionAll, + + FeatureMembership: resset.ActionRead, + FeatureBilling: resset.ActionRead, + FeatureAuthentication: resset.ActionRead, + + FeatureDeletion: resset.ActionNone, + FeatureDocumentSigning: resset.ActionNone, + } +) + +// PermittedRolesGetter is an interface for Accesses capable of indicating what +// roles are allowed for the operation. +type PermittedRolesGetter interface { + macaroon.Access + + // GetPermittedRoles returns a slice of roles that are allowed to perform the + // operation. + GetPermittedRoles() []Role +} + +var _ PermittedRolesGetter = (*Access)(nil) + +// GetPermittedRoles implements macaroon.PermittedRolesGetter. We require RoleAdmin +// for unrecognized organization features or features for which the attempted +// action is not allowed by ordinary members. +func (a *Access) GetPermittedRoles() []Role { + if a.Feature == nil { + return []Role{RoleMember} + } + + if memberAllowed, ok := MemberFeatures[*a.Feature]; ok && a.Action.IsSubsetOf(memberAllowed) { + return []Role{RoleMember} + } + + return []Role{RoleAdmin} +} + // OrgIDGetter is an interface allowing other packages to implement Accesses // that work with Caveats defined in this package. type OrgIDGetter interface { diff --git a/flyio/caveats.go b/flyio/caveats.go index a854b91..390f9e2 100644 --- a/flyio/caveats.go +++ b/flyio/caveats.go @@ -2,6 +2,7 @@ package flyio import ( "fmt" + "strings" "golang.org/x/exp/slices" @@ -20,10 +21,11 @@ const ( CavMachineFeatureSet = macaroon.CavFlyioMachineFeatureSet CavFromMachineSource = macaroon.CavFlyioFromMachineSource CavClusters = macaroon.CavFlyioClusters - CavNoAdminFeatures = macaroon.CavNoAdminFeatures + CavIsMember = macaroon.CavFlyioIsMember CavCommands = macaroon.CavFlyioCommands CavAppFeatureSet = macaroon.CavFlyioAppFeatureSet CavStorageObjects = macaroon.CavFlyioStorageObjects + CavAllowedRoles = macaroon.CavAllowedRoles ) type FromMachine struct { @@ -239,83 +241,99 @@ func (c *Clusters) Prohibits(a macaroon.Access) error { return c.Clusters.Prohibits(f.GetCluster(), f.GetAction()) } +// Role is used by the AllowedRoles and IsMember caveats. +type Role uint32 + const ( - FeatureWireGuard = "wg" - FeatureDomains = "domain" - FeatureSites = "site" - FeatureRemoteBuilders = "builder" - FeatureAddOns = "addon" - FeatureChecks = "checks" - FeatureLFSC = "litefs-cloud" - FeatureMembership = "membership" - FeatureBilling = "billing" - FeatureDeletion = "deletion" - FeatureDocumentSigning = "document_signing" - FeatureAuthentication = "authentication" -) + RoleMember Role = 1 << iota + RoleBillingManager + // add new roles here! don't forget to update roleNames -var ( - MemberFeatures = map[string]resset.Action{ - FeatureWireGuard: resset.ActionAll, - FeatureDomains: resset.ActionAll, - FeatureSites: resset.ActionAll, - FeatureRemoteBuilders: resset.ActionAll, - FeatureAddOns: resset.ActionAll, - FeatureChecks: resset.ActionAll, - FeatureLFSC: resset.ActionAll, - - FeatureMembership: resset.ActionRead, - FeatureBilling: resset.ActionRead, - FeatureAuthentication: resset.ActionRead, - - FeatureDeletion: resset.ActionNone, - FeatureDocumentSigning: resset.ActionNone, - } + RoleAdmin Role = 0xFFFFFFFF ) -// NoAdminFeatures is a shorthand for specifying that the token isn't allowed to -// access admin-only features. Same as: -// -// resset.IfPresent{ -// Ifs: macaroon.NewCaveatSet(&FeatureSet{ -// "memberFeatureOne": resset.ActionAll, -// "memberFeatureTwo": resset.ActionAll, -// "memberFeatureNNN": resset.ActionAll, -// }), -// Else: resset.ActionAll -// } -type NoAdminFeatures struct{} - -func init() { macaroon.RegisterCaveatType(&NoAdminFeatures{}) } -func (c *NoAdminFeatures) CaveatType() macaroon.CaveatType { return CavNoAdminFeatures } -func (c *NoAdminFeatures) Name() string { return "NoAdminFeatures" } - -func (c *NoAdminFeatures) Prohibits(a macaroon.Access) error { - f, isFlyioAccess := a.(FeatureGetter) - if !isFlyioAccess { - return fmt.Errorf("%w: access isnt FeatureGetter", macaroon.ErrInvalidAccess) +var roleNames = map[Role]string{ + // put roles that are a combination of other roles at the top + RoleAdmin: "admin", + + // put singular roles at the bottom + RoleMember: "member", + RoleBillingManager: "billing_manager", +} + +// HasAllRoles returns whether other is a subset of r. +func (r Role) HasAllRoles(other Role) bool { + return r&other == other +} + +func (r Role) String() string { + if r == 0 { + return "none" } - if f.GetFeature() == nil { - return nil + + if nr, ok := roleNames[r]; ok { + return nr } - if *f.GetFeature() == "" { - return fmt.Errorf("%w admin org features", resset.ErrUnauthorizedForResource) + + var ( + names []string + combined Role + ) + + for namedRole, name := range roleNames { + if r.HasAllRoles(namedRole) { + names = append(names, name) + combined |= namedRole + + if combined == r { + return strings.Join(names, "+") + } + } } - memberPermission, ok := MemberFeatures[*f.GetFeature()] - if !ok { - return fmt.Errorf("%w %s", resset.ErrUnauthorizedForResource, *f.GetFeature()) + return fmt.Sprintf("invalid(%d)", r) +} + +// AllowedRoles is a bitmask of roles that may be assumed. Only usable with +// Accesses implementing PermittedRolesGetter. Checks that a role returned by +// [GetPermittedRoles] matches the mask. +type AllowedRoles Role + +func init() { macaroon.RegisterCaveatType(new(AllowedRoles)) } +func (c *AllowedRoles) CaveatType() macaroon.CaveatType { return CavAllowedRoles } +func (c *AllowedRoles) Name() string { return "AllowedRoles" } + +func (c *AllowedRoles) Prohibits(a macaroon.Access) error { + f, isFlyioAccess := a.(PermittedRolesGetter) + if !isFlyioAccess { + return fmt.Errorf("%w: access isn't PermittedRolesGetter", macaroon.ErrInvalidAccess) } - if !f.GetAction().IsSubsetOf(memberPermission) { - return fmt.Errorf( - "%w %s access to %s", - resset.ErrUnauthorizedForAction, - f.GetAction().Remove(memberPermission), - *f.GetFeature(), - ) + + permittedRoles := f.GetPermittedRoles() + for _, permitted := range permittedRoles { + if Role(*c).HasAllRoles(permitted) { + return nil + } } - return nil + return fmt.Errorf("%w: allowed roles (%v) not permitted (%v)", ErrUnauthorizedForRole, *c, permittedRoles) +} + +// IsMember is an alias for RoleMask(RoleMember). It used to be called +// NoAdminFeatures. +type IsMember struct{} + +func init() { + macaroon.RegisterCaveatType(&IsMember{}) + macaroon.RegisterCaveatJSONAlias(CavIsMember, "NoAdminFeatures") +} + +func (c *IsMember) CaveatType() macaroon.CaveatType { return CavIsMember } +func (c *IsMember) Name() string { return "IsMember" } + +func (c *IsMember) Prohibits(a macaroon.Access) error { + ar := AllowedRoles(RoleMember) + return ar.Prohibits(a) } // Commands is a list of commands allowed by this token. diff --git a/flyio/caveats_test.go b/flyio/caveats_test.go index 0e33574..244b883 100644 --- a/flyio/caveats_test.go +++ b/flyio/caveats_test.go @@ -21,7 +21,8 @@ func TestCaveatSerialization(t *testing.T) { &MachineFeatureSet{Features: resset.New(resset.ActionRead, "123")}, &FromMachine{ID: "asdf"}, &Clusters{Clusters: resset.New(resset.ActionRead, "123")}, - &NoAdminFeatures{}, + &IsMember{}, + ptr(AllowedRoles(RoleAdmin)), &Commands{Command{[]string{"123"}, true}}, ) @@ -40,61 +41,107 @@ func TestCaveatSerialization(t *testing.T) { assert.Equal(t, cs, cs2) } -func TestNoAdminFeatures(t *testing.T) { - cs := macaroon.NewCaveatSet(&NoAdminFeatures{}) +func TestAllowedRoles(t *testing.T) { + csMember := macaroon.NewCaveatSet(&IsMember{}) + csAdmin := macaroon.NewCaveatSet(ptr(AllowedRoles(RoleAdmin))) - yes := func(access *Access) { + yes := func(cs *macaroon.CaveatSet, access *Access) { t.Helper() assert.NoError(t, cs.Validate(access)) } - no := func(access *Access, target error) { + no := func(cs *macaroon.CaveatSet, access *Access, target error) { t.Helper() err := cs.Validate(access) assert.Error(t, err) assert.IsError(t, err, target) } - yes(&Access{ + yes(csMember, &Access{ + OrgID: uptr(1), + Action: resset.ActionAll, + Feature: ptr("wg"), + }) + yes(csAdmin, &Access{ OrgID: uptr(1), Action: resset.ActionAll, Feature: ptr("wg"), }) - yes(&Access{ + yes(csMember, &Access{ + OrgID: uptr(1), + Action: resset.ActionRead, + Feature: ptr("membership"), + }) + yes(csAdmin, &Access{ OrgID: uptr(1), Action: resset.ActionRead, Feature: ptr("membership"), }) - yes(&Access{ + yes(csMember, &Access{ + OrgID: uptr(1), + Action: resset.ActionAll, + }) + yes(csAdmin, &Access{ OrgID: uptr(1), Action: resset.ActionAll, }) - no(&Access{ + no(csMember, &Access{ OrgID: uptr(1), Action: resset.ActionWrite, Feature: ptr("membership"), - }, resset.ErrUnauthorizedForAction) + }, ErrUnauthorizedForRole) + yes(csAdmin, &Access{ + OrgID: uptr(1), + Action: resset.ActionWrite, + Feature: ptr("membership"), + }) - no(&Access{ + no(csMember, &Access{ OrgID: uptr(1), Action: resset.ActionRead, Feature: ptr("unknown"), - }, resset.ErrUnauthorizedForResource) + }, ErrUnauthorizedForRole) + yes(csAdmin, &Access{ + OrgID: uptr(1), + Action: resset.ActionRead, + Feature: ptr("unknown"), + }) - no(&Access{ + no(csMember, &Access{ OrgID: uptr(1), Action: resset.ActionNone, Feature: ptr(""), - }, resset.ErrUnauthorizedForResource) + }, ErrUnauthorizedForRole) + yes(csAdmin, &Access{ + OrgID: uptr(1), + Action: resset.ActionNone, + Feature: ptr(""), + }) - no(&Access{ + no(csMember, &Access{ OrgID: uptr(1), Action: resset.ActionNone, Feature: ptr(""), - }, resset.ErrUnauthorizedForResource) + }, ErrUnauthorizedForRole) + yes(csAdmin, &Access{ + OrgID: uptr(1), + Action: resset.ActionNone, + Feature: ptr(""), + }) +} + +func TestRole(t *testing.T) { + assert.Equal(t, "admin", RoleAdmin.String()) + assert.Equal(t, "member", RoleMember.String()) + assert.Equal(t, "member+billing_manager", (RoleMember | RoleBillingManager).String()) + + assert.True(t, RoleAdmin.HasAllRoles(RoleAdmin)) + assert.True(t, RoleAdmin.HasAllRoles(RoleMember)) + assert.False(t, RoleMember.HasAllRoles(RoleAdmin)) + assert.False(t, RoleMember.HasAllRoles(RoleBillingManager)) } func TestCommands(t *testing.T) { diff --git a/flyio/errors.go b/flyio/errors.go new file mode 100644 index 0000000..5e6f76d --- /dev/null +++ b/flyio/errors.go @@ -0,0 +1,11 @@ +package flyio + +import ( + "fmt" + + "github.com/superfly/macaroon" +) + +var ( + ErrUnauthorizedForRole = fmt.Errorf("%w for role", macaroon.ErrUnauthorized) +) diff --git a/internal/test-vectors/test_vectors.go b/internal/test-vectors/test_vectors.go index d2dfb36..e9e5089 100644 --- a/internal/test-vectors/test_vectors.go +++ b/internal/test-vectors/test_vectors.go @@ -134,7 +134,7 @@ var caveats = macaroon.NewCaveatSet( 0xDE, 0xAD, 0xBE, 0xEF, 123, })), - &flyio.NoAdminFeatures{}, + &flyio.IsMember{}, &flyio.Organization{ID: 123, Mask: resset.ActionAll}, )