diff --git a/auth/caveats.go b/auth/caveats.go new file mode 100644 index 0000000..4370063 --- /dev/null +++ b/auth/caveats.go @@ -0,0 +1,192 @@ +package auth + +import ( + "fmt" + "math" + "time" + + "golang.org/x/exp/slices" + + "github.com/superfly/macaroon" +) + +const ( + CavConfineUser = macaroon.CavAuthConfineUser + CavConfineOrganization = macaroon.CavAuthConfineOrganization + CavConfineGoogleHD = macaroon.CavAuthConfineGoogleHD + CavConfineGitHubOrg = macaroon.CavAuthConfineGitHubOrg + CavMaxValidity = macaroon.CavAuthMaxValidity +) + +// ConfineOrganization is a requirement placed on 3P caveats, requiring that the +// authenticated used be associated with OrgID. It has no meaning in a 1P setting. +type ConfineOrganization struct { + ID uint64 `json:"id"` +} + +func RequireOrganization(id uint64) *ConfineOrganization { + return &ConfineOrganization{id} +} + +// Implements macaroon.Caveat +func init() { macaroon.RegisterCaveatType(&ConfineOrganization{}) } +func (c *ConfineOrganization) CaveatType() macaroon.CaveatType { return CavConfineOrganization } +func (c *ConfineOrganization) Name() string { return "ConfineOrganization" } + +// Implements macaroon.Caveat +func (c *ConfineOrganization) Prohibits(a macaroon.Access) error { + switch dr, isDR := a.(*DischargeRequest); { + case !isDR: + return macaroon.ErrInvalidAccess + case dr.Organization == nil: + return c + case dr.Organization.ID != c.ID: + return fmt.Errorf("%w (got %d)", c, dr.Organization.ID) + default: + return nil + } +} + +// implements error +func (c *ConfineOrganization) Error() string { + return fmt.Sprintf("must authenticate with Fly.io account with access to organization %d", c.ID) +} + +// ConfineUser is a caveat limiting this token to a specific user ID. +type ConfineUser struct { + ID uint64 `json:"id"` +} + +func RequireUser(id uint64) *ConfineUser { + return &ConfineUser{id} +} + +// Implements macaroon.Caveat +func init() { macaroon.RegisterCaveatType(&ConfineUser{}) } +func (c *ConfineUser) CaveatType() macaroon.CaveatType { return CavConfineUser } +func (c *ConfineUser) Name() string { return "ConfineUser" } + +// Implements macaroon.Caveat +func (c *ConfineUser) Prohibits(a macaroon.Access) error { + switch dr, isDR := a.(*DischargeRequest); { + case !isDR: + return macaroon.ErrInvalidAccess + case dr.User == nil: + return c + case dr.User.ID != c.ID: + return fmt.Errorf("%w (got %d)", c, dr.User.ID) + default: + return nil + } +} + +// implements error +func (c *ConfineUser) Error() string { + return fmt.Sprintf("must authenticate with Fly.io account %d", c.ID) +} + +// Implements macaroon.Caveat and error. Requires that the user is +// authenticated to Google with an account in the specified HD. +type ConfineGoogleHD string + +func RequireGoogleHD(hd string) *ConfineGoogleHD { + return (*ConfineGoogleHD)(&hd) +} + +// Implements macaroon.Caveat +func init() { macaroon.RegisterCaveatType(new(ConfineGoogleHD)) } +func (c *ConfineGoogleHD) CaveatType() macaroon.CaveatType { return CavConfineGoogleHD } +func (c *ConfineGoogleHD) Name() string { return "ConfineGoogleHD" } + +// Implements macaroon.Caveat +func (c *ConfineGoogleHD) Prohibits(a macaroon.Access) error { + switch dr, isDR := a.(*DischargeRequest); { + case !isDR: + return macaroon.ErrInvalidAccess + case dr.Google == nil: + return c + case dr.Google.HD != string(*c): + return fmt.Errorf("%w (got %s)", c, dr.Google.HD) + default: + return nil + } +} + +// implements error +func (c *ConfineGoogleHD) Error() string { + return fmt.Sprintf("must authenticate with %s Google account", string(*c)) +} + +// Implements macaroon.Caveat and error. Requires that the user is +// authenticated to GitHub with an account that has access the specified org. +type ConfineGitHubOrg uint64 + +func RequireGitHubOrg(id uint64) *ConfineGitHubOrg { + return (*ConfineGitHubOrg)(&id) +} + +// Implements macaroon.Caveat +func init() { macaroon.RegisterCaveatType(new(ConfineGitHubOrg)) } +func (c *ConfineGitHubOrg) CaveatType() macaroon.CaveatType { return CavConfineGitHubOrg } +func (c *ConfineGitHubOrg) Name() string { return "ConfineGitHubOrg" } + +// Implements macaroon.Caveat +func (c *ConfineGitHubOrg) Prohibits(a macaroon.Access) error { + switch dr, isDR := a.(*DischargeRequest); { + case !isDR: + return macaroon.ErrInvalidAccess + case dr.GitHub == nil: + return c + case !slices.Contains(dr.GitHub.OrgIDs, uint64(*c)): + return fmt.Errorf("%w (got %v)", c, dr.GitHub.OrgIDs) + default: + return nil + } +} + +// implements error +func (c *ConfineGitHubOrg) Error() string { + return fmt.Sprintf("must authenticate with GitHub account with access to organization %d", uint64(*c)) +} + +// Implements macaroon.Caveat. Limits the validity window length (seconds) of +// discharges issued by 3ps. +type MaxValidity uint64 + +// Implements macaroon.Caveat +func init() { macaroon.RegisterCaveatType(new(MaxValidity)) } +func (c *MaxValidity) CaveatType() macaroon.CaveatType { return CavMaxValidity } +func (c *MaxValidity) Name() string { return "MaxValidity" } + +// Implements macaroon.Caveat +func (c *MaxValidity) Prohibits(a macaroon.Access) error { + switch aa, isAuthAccess := a.(*DischargeRequest); { + case !isAuthAccess: + return macaroon.ErrInvalidAccess + case aa.Expiry.Sub(aa.Now()) > c.duration(): + return fmt.Errorf( + "%w: %v exceeds max validity window (%v)", + macaroon.ErrUnauthorized, + aa.Expiry.Sub(aa.Now()), + c.duration(), + ) + default: + return nil + } +} + +func (c *MaxValidity) duration() time.Duration { + return time.Duration(*c) * time.Second +} + +func GetMaxValidity(cs *macaroon.CaveatSet) (time.Duration, bool) { + max := time.Duration(math.MaxInt64) + + for _, cav := range macaroon.GetCaveats[*MaxValidity](cs) { + if cavDur := cav.duration(); max > cavDur { + max = cavDur + } + } + + return max, max != time.Duration(math.MaxInt64) +} diff --git a/auth/caveats_test.go b/auth/caveats_test.go new file mode 100644 index 0000000..907f0fe --- /dev/null +++ b/auth/caveats_test.go @@ -0,0 +1,32 @@ +package auth + +import ( + "encoding/json" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/superfly/macaroon" +) + +func TestCaveatSerialization(t *testing.T) { + cs := macaroon.NewCaveatSet( + RequireUser(123), + RequireOrganization(123), + RequireGoogleHD("123"), + RequireGitHubOrg(123), + ) + + b, err := json.Marshal(cs) + assert.NoError(t, err) + + cs2 := macaroon.NewCaveatSet() + err = json.Unmarshal(b, cs2) + assert.NoError(t, err) + assert.Equal(t, cs, cs2) + + b, err = cs.MarshalMsgpack() + assert.NoError(t, err) + cs2, err = macaroon.DecodeCaveats(b) + assert.NoError(t, err) + assert.Equal(t, cs, cs2) +} diff --git a/auth/discharge_request.go b/auth/discharge_request.go new file mode 100644 index 0000000..d3b6c9f --- /dev/null +++ b/auth/discharge_request.go @@ -0,0 +1,31 @@ +package auth + +import "time" + +// implements macaroon.Access +type DischargeRequest struct { + User *UserAuth + Organization *OrganizationAuth + Google *GoogleAuth + GitHub *GitHubAuth + Expiry time.Time +} + +func (a *DischargeRequest) Now() time.Time { return time.Now() } +func (a *DischargeRequest) Validate() error { return nil } + +type UserAuth struct { + ID uint64 +} + +type OrganizationAuth struct { + ID uint64 +} + +type GoogleAuth struct { + HD string +} + +type GitHubAuth struct { + OrgIDs []uint64 +} diff --git a/caveat.go b/caveat.go index f011134..ce07ec8 100644 --- a/caveat.go +++ b/caveat.go @@ -21,8 +21,8 @@ const ( CavFlyioFeatureSet CavFlyioMutations CavFlyioMachines - CavFlyioConfineUser - CavFlyioConfineOrganization + CavAuthConfineUser + CavAuthConfineOrganization CavFlyioIsUser Cav3P CavBindToParentToken @@ -32,9 +32,9 @@ const ( CavFlyioClusters _ // fly.io reserved _ // fly.io reserved - CavFlyioRequireGoogleHD - CavFlyioRequireGitHubOrg - CavFlyioDischargeExpiryLTE + CavAuthConfineGoogleHD + CavAuthConfineGitHubOrg + CavAuthMaxValidity // Globally-recognized user-registerable caveat types may be requested via // pull requests to this repository. Add a meaningful name of the caveat diff --git a/flyio/caveats.go b/flyio/caveats.go index cf464af..893b0a6 100644 --- a/flyio/caveats.go +++ b/flyio/caveats.go @@ -8,18 +8,16 @@ import ( ) const ( - CavOrganization = macaroon.CavFlyioOrganization - CavVolumes = macaroon.CavFlyioVolumes - CavApps = macaroon.CavFlyioApps - CavFeatureSet = macaroon.CavFlyioFeatureSet - CavMutations = macaroon.CavFlyioMutations - CavMachines = macaroon.CavFlyioMachines - CavConfineUser = macaroon.CavFlyioConfineUser - CavConfineOrganization = macaroon.CavFlyioConfineOrganization - CavIsUser = macaroon.CavFlyioIsUser - CavMachineFeatureSet = macaroon.CavFlyioMachineFeatureSet - CavFromMachineSource = macaroon.CavFlyioFromMachineSource - CavClusters = macaroon.CavFlyioClusters + CavOrganization = macaroon.CavFlyioOrganization + CavVolumes = macaroon.CavFlyioVolumes + CavApps = macaroon.CavFlyioApps + CavFeatureSet = macaroon.CavFlyioFeatureSet + CavMutations = macaroon.CavFlyioMutations + CavMachines = macaroon.CavFlyioMachines + CavIsUser = macaroon.CavFlyioIsUser + CavMachineFeatureSet = macaroon.CavFlyioMachineFeatureSet + CavFromMachineSource = macaroon.CavFlyioFromMachineSource + CavClusters = macaroon.CavFlyioClusters ) type FromMachine struct { @@ -76,35 +74,6 @@ func (c *Organization) Prohibits(a macaroon.Access) error { } } -// ConfineOrganization is a requirement placed on 3P caveats, requiring that the -// authenticated used be associated with OrgID. It has no meaning in a 1P setting. -type ConfineOrganization struct { - ID uint64 `json:"id"` -} - -func init() { macaroon.RegisterCaveatType(&ConfineOrganization{}) } -func (c *ConfineOrganization) CaveatType() macaroon.CaveatType { return CavConfineOrganization } -func (c *ConfineOrganization) Name() string { return "ConfineOrganization" } - -func (c *ConfineOrganization) Prohibits(macaroon.Access) error { - // ConfineOrganization is only used in 3P caveats and has no role in access validation. - return fmt.Errorf("%w (confine-organization)", macaroon.ErrBadCaveat) -} - -// ConfineUser is a caveat limiting this token to a specific user ID. -type ConfineUser struct { - ID uint64 `json:"id"` -} - -func init() { macaroon.RegisterCaveatType(&ConfineUser{}) } -func (c *ConfineUser) CaveatType() macaroon.CaveatType { return CavConfineUser } -func (c *ConfineUser) Name() string { return "ConfineUser" } - -func (c *ConfineUser) Prohibits(macaroon.Access) error { - // ConfineUser is only used in 3P caveats and has no role in access validation. - return fmt.Errorf("%w (confine-user)", macaroon.ErrBadCaveat) -} - // Apps is a set of App caveats, with their RWX access levels. A token with this set can be used // only with the listed apps, regardless of what the token says. Additional Apps can be added, // but they can only narrow, not expand, which apps (or access levels) can be reached from the token. diff --git a/flyio/caveats_test.go b/flyio/caveats_test.go index faef58e..88f76f4 100644 --- a/flyio/caveats_test.go +++ b/flyio/caveats_test.go @@ -17,8 +17,6 @@ func TestCaveatSerialization(t *testing.T) { &Volumes{Volumes: resset.New(resset.ActionRead, "123")}, &Machines{Machines: resset.New(resset.ActionRead, "123")}, &Mutations{Mutations: []string{"123"}}, - &ConfineUser{ID: 123}, - &ConfineOrganization{ID: 123}, &IsUser{ID: 123}, &MachineFeatureSet{Features: resset.New(resset.ActionRead, "123")}, &FromMachine{ID: "asdf"},