Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a new basic type identity #563

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f76470c
poc: a metrics module for pebble
IronCore864 Nov 13, 2024
6d8ee59
chore: undo unnecessary change
IronCore864 Nov 14, 2024
b4abc9a
chore: undo unnecessary change
IronCore864 Nov 14, 2024
a274276
chore: undo unnecessary change
IronCore864 Nov 14, 2024
a2c07e6
chore: metrics identity basic auth poc
IronCore864 Nov 26, 2024
4ebb633
chore: a poc for metrics with labels
IronCore864 Nov 27, 2024
7468b95
poc: remove adding identities using env vars according to comment in …
IronCore864 Nov 28, 2024
790a8f9
chore: update tests for the metrics lib poc
IronCore864 Nov 28, 2024
272005b
chore: refactor identities and access according to spec review
IronCore864 Dec 9, 2024
5be3e96
feat: use sha512 to verify password
IronCore864 Jan 21, 2025
a6c374d
feat: move the metrics api to /v1/metrics
IronCore864 Jan 21, 2025
1bd54cb
chore: remove Username from apiBasicIdentity
IronCore864 Jan 21, 2025
98ea11e
chore: revert changes on user state
IronCore864 Jan 21, 2025
68c18b7
Merge branch 'master' into poc-custom-metrics-lib
IronCore864 Jan 21, 2025
7fc255e
chore: fix failed identity tests
IronCore864 Jan 21, 2025
31a0617
test: unit tests for basic identity
IronCore864 Jan 22, 2025
306f2d3
feat: add basic identity
IronCore864 Jan 23, 2025
6c53491
chore: update comments
IronCore864 Jan 23, 2025
e49419d
chore: refactor according to review and add more unit tests
IronCore864 Feb 10, 2025
35794e6
Merge branch 'master' into basic-identity
IronCore864 Feb 13, 2025
c8f3aba
chore: prioritize basic type identity, add tests
IronCore864 Feb 13, 2025
af5e0b2
test: add more tests
IronCore864 Feb 13, 2025
edccbac
chore: prioritize basic type
IronCore864 Feb 13, 2025
c49f565
chore: refactor after review
IronCore864 Feb 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion client/identities.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ type Identity struct {
Access IdentityAccess `json:"access" yaml:"access"`

// One or more of the following type-specific configuration fields must be
// non-nil (currently the only type is "local").
// non-nil.
Local *LocalIdentity `json:"local,omitempty" yaml:"local,omitempty"`
Basic *BasicIdentity `json:"basic,omitempty" yaml:"basic,omitempty"`
}

// IdentityAccess defines the access level for an identity.
Expand All @@ -47,6 +48,12 @@ type LocalIdentity struct {
UserID *uint32 `json:"user-id" yaml:"user-id"`
}

// BasicIdentity holds identity configuration specific to the "basic" type
// (for HTTP basic authentication).
type BasicIdentity struct {
Password string `json:"password" yaml:"password"`
}

// For future extension.
type IdentitiesOptions struct{}

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/canonical/pebble
go 1.22

require (
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
github.com/canonical/go-flags v0.0.0-20230403090104-105d09a091b8
github.com/canonical/x-go v0.0.0-20230522092633-7947a7587f5b
github.com/gorilla/mux v1.8.1
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/canonical/go-flags v0.0.0-20230403090104-105d09a091b8 h1:zGaJEJI9qPVyM+QKFJagiyrM91Ke5S9htoL1D470g6E=
github.com/canonical/go-flags v0.0.0-20230403090104-105d09a091b8/go.mod h1:ZZFeR9K9iGgpwOaLYF9PdT44/+lfSJ9sQz3B+SsGsYU=
github.com/canonical/x-go v0.0.0-20230522092633-7947a7587f5b h1:Da2fardddn+JDlVEYtrzBLTtyzoyU3nIS0Cf0GvjmwU=
github.com/canonical/x-go v0.0.0-20230522092633-7947a7587f5b/go.mod h1:upTK9n6rlqITN9rCN69hdreI37dRDFUk2thlGGD5Cg8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
Expand All @@ -15,6 +19,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk=
github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand Down
3 changes: 3 additions & 0 deletions internals/cli/cmd_identities.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ func (cmd *cmdIdentities) writeText(identities map[string]*client.Identity) erro
if identity.Local != nil {
types = append(types, "local")
}
if identity.Basic != nil {
types = append(types, "basic")
}
sort.Strings(types)
if len(types) == 0 {
types = append(types, "unknown")
Expand Down
15 changes: 15 additions & 0 deletions internals/daemon/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,18 @@ func (ac UserAccess) CheckAccess(d *Daemon, r *http.Request, user *UserState) Re
// An identity explicitly set to "access: untrusted" isn't allowed.
return Unauthorized(accessDenied)
}

// MetricsAccess allows requests over the HTTP from authenticated users.
type MetricsAccess struct{}

func (ac MetricsAccess) CheckAccess(d *Daemon, r *http.Request, user *UserState) Response {
if user == nil {
return Unauthorized(accessDenied)
}
switch user.Access {
case state.MetricsAccess, state.AdminAccess:
return nil
}
// An identity explicitly set to "access: untrusted" isn't allowed.
return Unauthorized(accessDenied)
}
22 changes: 14 additions & 8 deletions internals/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,22 +147,27 @@ const (
accessForbidden
)

func userFromRequest(st *state.State, r *http.Request, ucred *Ucrednet) (*UserState, error) {
if ucred == nil {
// No ucred details, no UserState. Currently, "local" (ucred-based) is
// the only type of identity we support.
return nil, nil
func userFromRequest(st *state.State, r *http.Request, ucred *Ucrednet, username, password string) (*UserState, error) {
var userID *uint32
if ucred != nil {
userID = &ucred.Uid
}

st.Lock()
identity := st.IdentityFromInputs(&ucred.Uid)
identity := st.IdentityFromInputs(userID, username, password)
st.Unlock()

if identity == nil {
// No identity that matches these inputs (for now, just UID).
return nil, nil
}
return &UserState{Access: identity.Access, UID: &ucred.Uid}, nil
if identity.Basic != nil {
// Prioritize basic type and ignore UID in this case.
return &UserState{Access: identity.Access}, nil
} else if identity.Local != nil {
return &UserState{Access: identity.Access, UID: userID}, nil
}
return nil, nil
}

func (d *Daemon) Overlord() *overlord.Overlord {
Expand Down Expand Up @@ -213,7 +218,8 @@ func (c *Command) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// not good: https://github.com/canonical/pebble/pull/369
var user *UserState
if _, isOpen := access.(OpenAccess); !isOpen {
user, err = userFromRequest(c.d.state, r, ucred)
username, password, _ := r.BasicAuth()
user, err = userFromRequest(c.d.state, r, ucred, username, password)
if err != nil {
Forbidden("forbidden").ServeHTTP(w, r)
return
Expand Down
132 changes: 108 additions & 24 deletions internals/overlord/state/identities.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"

"github.com/GehirnInc/crypt/sha512_crypt"
)

// Identity holds the configuration of a single identity.
Expand All @@ -28,8 +31,9 @@ type Identity struct {
Access IdentityAccess

// One or more of the following type-specific configuration fields must be
// non-nil (currently the only type is "local").
// non-nil.
Local *LocalIdentity
Basic *BasicIdentity
}

// IdentityAccess defines the access level for an identity.
Expand All @@ -38,6 +42,7 @@ type IdentityAccess string
const (
AdminAccess IdentityAccess = "admin"
ReadAccess IdentityAccess = "read"
MetricsAccess IdentityAccess = "metrics"
UntrustedAccess IdentityAccess = "untrusted"
)

Expand All @@ -47,28 +52,63 @@ type LocalIdentity struct {
UserID uint32
}

// BasicIdentity holds identity configuration specific to the "basic" type
// (for HTTP basic authentication).
type BasicIdentity struct {
Password string // Note: this is the sha512-crypt hashed password.
}

// This is used to ensure we send a well-formed identity Name.
var identityNameRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_\-]*$`)

// validate checks that the identity is valid, returning an error if not.
func (d *Identity) validate() error {
func (d *Identity) validate(name string) error {
if d == nil {
return errors.New("identity must not be nil")
}

if !identityNameRegexp.MatchString(name) {
return fmt.Errorf("identity name %q invalid: must start with an alphabetic character and only contain alphanumeric characters, underscore, and hyphen", d.Name)
}

return d.validateExcludingName()
}

// validateExcludingName checks that the identity is valid, returning an error if not.
func (d *Identity) validateExcludingName() error {
if d == nil {
return errors.New("identity must not be nil")
}

switch d.Access {
case AdminAccess, ReadAccess, UntrustedAccess:
case AdminAccess, ReadAccess, MetricsAccess, UntrustedAccess:
// Check basic type access level.
if d.Basic != nil && d.Access != MetricsAccess {
return fmt.Errorf("basic identity can only have %q access, got %q", MetricsAccess, d.Access)
}
case "":
return fmt.Errorf("access value must be specified (%q, %q, or %q)",
AdminAccess, ReadAccess, UntrustedAccess)
return fmt.Errorf("access value must be specified (%q, %q, %q, or %q)",
AdminAccess, ReadAccess, MetricsAccess, UntrustedAccess)
default:
return fmt.Errorf("invalid access value %q, must be %q, %q, or %q",
d.Access, AdminAccess, ReadAccess, UntrustedAccess)
return fmt.Errorf("invalid access value %q, must be %q, %q, %q, or %q",
d.Access, AdminAccess, ReadAccess, MetricsAccess, UntrustedAccess)
}

switch {
case d.Local != nil:
return nil
default:
return errors.New(`identity must have at least one type ("local")`)
gotType := false
if d.Local != nil {
gotType = true
}
if d.Basic != nil {
if d.Basic.Password == "" {
return errors.New("basic identity must specify password (hashed)")
}
gotType = true
}
if !gotType {
return errors.New(`identity must have at least one type ("local" or "basic")`)
}

return nil
}

// apiIdentity exists so the default JSON marshalling of an Identity (used
Expand All @@ -77,17 +117,27 @@ func (d *Identity) validate() error {
type apiIdentity struct {
Access string `json:"access"`
Local *apiLocalIdentity `json:"local,omitempty"`
Basic *apiBasicIdentity `json:"basic,omitempty"`
}

type apiLocalIdentity struct {
UserID *uint32 `json:"user-id"`
}

type apiBasicIdentity struct {
Password string `json:"password"`
}

// IMPORTANT NOTE: be sure to exclude secrets when adding to this!
func (d *Identity) MarshalJSON() ([]byte, error) {
ai := apiIdentity{
Access: string(d.Access),
Local: &apiLocalIdentity{UserID: &d.Local.UserID},
}
if d.Local != nil {
ai.Local = &apiLocalIdentity{UserID: &d.Local.UserID}
}
if d.Basic != nil {
ai.Basic = &apiBasicIdentity{Password: "*****"}
}
return json.Marshal(ai)
}
Expand All @@ -102,15 +152,22 @@ func (d *Identity) UnmarshalJSON(data []byte) error {
identity := Identity{
Access: IdentityAccess(ai.Access),
}
switch {
case ai.Local != nil:

if ai.Local != nil {
if ai.Local.UserID == nil {
return errors.New("local identity must specify user-id")
}
identity.Local = &LocalIdentity{UserID: *ai.Local.UserID}
}
if ai.Basic != nil {
if ai.Basic.Password == "" {
return errors.New("basic identity must specify password (hashed)")
}
identity.Basic = &BasicIdentity{Password: ai.Basic.Password}
}

// Perform additional validation using the local Identity type.
err = identity.validate()
err = identity.validateExcludingName()
if err != nil {
return err
}
Expand All @@ -130,10 +187,11 @@ func (s *State) AddIdentities(identities map[string]*Identity) error {
if _, ok := s.identities[name]; ok {
existing = append(existing, name)
}
err := identity.validate()
err := identity.validate(name)
if err != nil {
return fmt.Errorf("identity %q invalid: %w", name, err)
}
identity.Name = name
}
if len(existing) > 0 {
sort.Strings(existing)
Expand Down Expand Up @@ -167,7 +225,7 @@ func (s *State) UpdateIdentities(identities map[string]*Identity) error {
if _, ok := s.identities[name]; !ok {
missing = append(missing, name)
}
err := identity.validate()
err := identity.validate(name)
if err != nil {
return fmt.Errorf("identity %q invalid: %w", name, err)
}
Expand Down Expand Up @@ -201,7 +259,7 @@ func (s *State) ReplaceIdentities(identities map[string]*Identity) error {

for name, identity := range identities {
if identity != nil {
err := identity.validate()
err := identity.validate(name)
if err != nil {
return fmt.Errorf("identity %q invalid: %w", name, err)
}
Expand Down Expand Up @@ -266,17 +324,43 @@ func (s *State) Identities() map[string]*Identity {

// IdentityFromInputs returns an identity with the given inputs, or nil
// if there is none.
func (s *State) IdentityFromInputs(userID *uint32) *Identity {
//
// Identity priority:
// 1. If both username and password are provided, the function attempts to
// match a basic type identity. The userID is ignored in this case. If
// a matching username is found but the password verification fails, nil
// is returned immediately.
// 2. If username and password are not both provided, the function attempts to
// match a local type identity using the userID.
//
// If no matching identity is found for the given inputs, nil is returned.
func (s *State) IdentityFromInputs(userID *uint32, username, password string) *Identity {
s.reading()

for _, identity := range s.identities {
switch {
case identity.Local != nil && userID != nil:
if identity.Local.UserID == *userID {
switch {
case username != "" || password != "":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we probably want && here (which matches the comment above too - "if both username and password are provided").

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per Harry's comment, I think we want to be strict and on the safer side: since both user/pass are intentionally set by the user if any is set, it means the user doesn't want to use the "basic" type identity, and it's likely that they used a empty password. If we change it to &&, the user might get a basic type identity, if it also has read access, it will be granted metrics access.

For example, if the identities are:

"bob": {
	Access: state.ReadAccess,
	Local:  &state.LocalIdentity{UserID: 42},
},
"nancy": {
	Access: state.MetricsAccess,
	Basic:  &state.BasicIdentity{Password: "hash"},
},

And if the user input is (42, "nancy", ""), it will match bob although the user's intention is to use nancy since they set the username explicitly.

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, that makes sense. In that case, let's just update the doc comment to match.

// Prioritize username/password if provided, because they come from HTTP
// Authorization header, a per-request, client controlled property. If set
// by the client, it's intentional, so it should have a higher priority.
for _, identity := range s.identities {
if identity.Basic != nil && identity.Name == username {
crypt := sha512_crypt.New()
err := crypt.Verify(identity.Basic.Password, []byte(password))
if err == nil {
return identity
} else {
return nil
}
}
}
case userID != nil:
for _, identity := range s.identities {
if identity.Local != nil && identity.Local.UserID == *userID {
return identity
}
}
}

return nil
}

Expand Down
Loading
Loading