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: agent registry auth #747

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 6 additions & 5 deletions cmd/agent/docker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import (
)

var (
interval = 5 * time.Second
logger = util.Require(zap.NewDevelopment())
client = util.Require(agentclient.NewFromEnv(logger))
agentVersionID = os.Getenv("DISTR_AGENT_VERSION_ID")
interval = 5 * time.Second
logger = util.Require(zap.NewDevelopment())
client = util.Require(agentclient.NewFromEnv(logger))
agentVersionID = os.Getenv("DISTR_AGENT_VERSION_ID")
distrRegistryHost = os.Getenv("DISTR_REGISTRY_HOST")
)

func init() {
Expand Down Expand Up @@ -118,7 +119,7 @@ loop:

var agentDeployment *AgentDeployment
var status string
_, err = agentauth.EnsureAuth(ctx, resource.Deployment.AgentDeployment)
_, err = agentauth.EnsureAuth(ctx, distrRegistryHost, client.RawToken(), resource.Deployment.AgentDeployment)
if err != nil {
logger.Error("docker auth error", zap.Error(err))
} else if agentDeployment, status, err = ApplyComposeFile(ctx, *resource.Deployment); err == nil {
Expand Down
3 changes: 2 additions & 1 deletion cmd/agent/kubernetes/helm_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ func GetHelmActionConfig(

var cfg action.Configuration
if deployment != nil {
if authorizer, err := agentauth.EnsureAuth(ctx, *deployment); err != nil {
if authorizer, err :=
agentauth.EnsureAuth(ctx, distrRegistryHost, agentClient.RawToken(), *deployment); err != nil {
return nil, err
} else if rc, err := registry.NewClient(registry.ClientOptAuthorizer(authorizer)); err != nil {
return nil, err
Expand Down
17 changes: 9 additions & 8 deletions cmd/agent/kubernetes/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ import (
)

var (
interval = 5 * time.Second
logger = util.Require(zap.NewDevelopment())
agentClient = util.Require(agentclient.NewFromEnv(logger))
k8sConfigFlags = genericclioptions.NewConfigFlags(true)
k8sClient = util.Require(kubernetes.NewForConfig(util.Require(k8sConfigFlags.ToRESTConfig())))
k8sDynamicClient = util.Require(dynamic.NewForConfig(util.Require(k8sConfigFlags.ToRESTConfig())))
k8sRestMapper = util.Require(k8sConfigFlags.ToRESTMapper())
agentVersionId = os.Getenv("DISTR_AGENT_VERSION_ID")
interval = 5 * time.Second
logger = util.Require(zap.NewDevelopment())
agentClient = util.Require(agentclient.NewFromEnv(logger))
k8sConfigFlags = genericclioptions.NewConfigFlags(true)
k8sClient = util.Require(kubernetes.NewForConfig(util.Require(k8sConfigFlags.ToRESTConfig())))
k8sDynamicClient = util.Require(dynamic.NewForConfig(util.Require(k8sConfigFlags.ToRESTConfig())))
k8sRestMapper = util.Require(k8sConfigFlags.ToRESTMapper())
agentVersionId = os.Getenv("DISTR_AGENT_VERSION_ID")
distrRegistryHost = os.Getenv("DISTR_REGISTRY_HOST")
)

func init() {
Expand Down
16 changes: 15 additions & 1 deletion internal/agentauth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
var previousAuth = map[uuid.UUID]map[string]api.AgentRegistryAuth{}
var authClients = map[uuid.UUID]auth.Client{}

func EnsureAuth(ctx context.Context, deployment api.AgentDeployment) (auth.Client, error) {
func EnsureAuth(
ctx context.Context,
distrRegistryHost, jwt string,
deployment api.AgentDeployment,
) (auth.Client, error) {
if err := os.MkdirAll(DockerConfigDir(deployment), 0o700); err != nil {
return nil, fmt.Errorf("could not create docker config dir for deployment: %w", err)
}
Expand All @@ -32,6 +36,16 @@
authClients[deployment.ID] = c
client = c
}

if distrRegistryHost != "" {
client.LoginWithOpts(

Check failure on line 41 in internal/agentauth/auth.go

View workflow job for this annotation

GitHub Actions / Build

Error return value of `client.LoginWithOpts` is not checked (errcheck)
auth.WithLoginContext(ctx),
auth.WithLoginInsecure(),
auth.WithLoginHostname(distrRegistryHost),
auth.WithLoginUsername("unused"),
auth.WithLoginSecret(jwt),
)
}
}

if !maps.Equal(previousAuth[deployment.ID], deployment.RegistryAuth) {
Expand Down
4 changes: 4 additions & 0 deletions internal/agentclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ func (c *Client) HasTokenExpiredAfter(t time.Time) bool {
return c.token == nil || c.token.Expiration().Before(t)
}

func (c *Client) RawToken() string {
return c.rawToken
}

func (c *Client) doAuthenticated(ctx context.Context, r *http.Request) (*http.Response, error) {
if err := c.EnsureToken(ctx); err != nil {
return nil, err
Expand Down
1 change: 1 addition & 0 deletions internal/agentmanifest/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func getTemplateData(
) map[string]any {
result := map[string]any{
"agentInterval": env.AgentInterval(),
"registryHost": env.RegistryHost(),
"agentDockerConfig": base64.StdEncoding.EncodeToString(env.AgentDockerConfig()),
"agentVersion": deploymentTarget.AgentVersion.Name,
"agentVersionId": deploymentTarget.AgentVersion.ID,
Expand Down
21 changes: 16 additions & 5 deletions internal/auth/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,25 @@
// ArtifactsAuthentication supports Basic auth login for OCI clients, where the password should be a PAT.
// The given PAT is verified against the database, to make sure that the user still exists.
var ArtifactsAuthentication = authn.New(
authn.Chain4(
authn.Chain(
token.NewExtractor(
token.WithExtractorFuncs(token.FromBasicAuth()),
token.WithErrorHeaders(map[string]string{"WWW-Authenticate": "Basic realm=\"Distr\""}),
token.WithErrorHeaders(http.Header{"WWW-Authenticate": []string{"Basic realm=\"Distr\""}}),
),
authn.Alternative(
// Auhtenticate UserAccount with PAT

Check failure on line 53 in internal/auth/authentication.go

View workflow job for this annotation

GitHub Actions / Build

`Auhtenticate` is a misspelling of `Authenticate` (misspell)
authn.Chain3(
authkey.Authenticator(),
authinfo.AuthKeyAuthenticator(),
authinfo.DbAuthenticator(),
),
// Authenticate with Agent JWT
authn.Chain3(
jwt.Authenticator(authjwt.JWTAuth),
authinfo.AgentJWTAuthenticator(),
authinfo.AgentDbAuthenticator(),
),
),
authkey.Authenticator(),
authinfo.AuthKeyAuthenticator(),
authinfo.DbAuthenticator(),
),
)

Expand Down
78 changes: 78 additions & 0 deletions internal/authn/agent/authenticator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package agent

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"github.com/glasskube/distr/internal/authjwt"
"github.com/glasskube/distr/internal/authn"
"github.com/glasskube/distr/internal/authn/authinfo"
"github.com/glasskube/distr/internal/authn/basic"
"github.com/glasskube/distr/internal/db"
"github.com/glasskube/distr/internal/security"
"github.com/glasskube/distr/internal/types"
"github.com/google/uuid"
)

func Authenticator() authn.Authenticator[basic.Auth, authinfo.AuthInfo] {
fn := func(ctx context.Context, basic basic.Auth) (out authinfo.AuthInfo, err error) {
if targetID, err1 := uuid.Parse(basic.Username); err1 != nil {
err = fmt.Errorf("%w: %w", authn.ErrBadAuthentication, err1)
} else if target, err1 := db.GetDeploymentTarget(ctx, targetID, nil); err1 != nil {
err = err1
} else if target.AccessKeyHash == nil || target.AccessKeySalt == nil {
err = errors.New("access key or salt is nil")
} else if err1 :=
security.VerifyAccessKey(*target.AccessKeySalt, *target.AccessKeyHash, basic.Password); err1 != nil {
err = fmt.Errorf("%w: %w", authn.ErrBadAuthentication, err1)
} else if user, err1 := db.GetUserAccountByID(ctx, target.CreatedByUserAccountID); err1 != nil {
err = err1
} else if orgs, err1 := db.GetOrganizationsForUser(ctx, user.ID); err1 != nil {
err = err1
} else if len(orgs) != 1 {
err = fmt.Errorf("user must have exactly one organization")
} else if orgs[0].UserRole != types.UserRoleCustomer {
err = fmt.Errorf("user must have role customer")
} else if _, token, err1 := authjwt.GenerateDefaultToken(*user, orgs[0]); err1 != nil {
err = err1
} else {
err = &tokenError{Token: token}
}
return
}
return authn.AuthenticatorFunc[basic.Auth, authinfo.AuthInfo](fn)
}

type tokenError struct {
Token string `json:"token"`
}

var _ authn.WithResponseHeaders = &tokenError{}
var _ authn.WithResponseStatus = &tokenError{}
var _ authn.ResponseBodyWriter = &tokenError{}

// Error implements error.
func (t *tokenError) Error() string {
return "NOT AN ERROR: agent successful token auth" // :^)
}

// ResponseHeaders implements authn.WithResponseHeaders.
func (t *tokenError) ResponseHeaders() http.Header {
result := http.Header{}
result.Add("Content-Type", "application/json")
return result
}

// ResponseStatus implements authn.WithResponseStatus.
func (t *tokenError) ResponseStatus() int {
return http.StatusOK
}

// WriteResponse implements authn.ResponseBodyWriter.
func (t *tokenError) WriteResponse(w io.Writer) {
_ = json.NewEncoder(w).Encode(t)
}
36 changes: 28 additions & 8 deletions internal/authn/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"errors"
"net/http"

"go.uber.org/multierr"
)

type contextKey struct{}
Expand Down Expand Up @@ -76,16 +78,34 @@ func (a *Authentication[T]) ValidatorMiddleware(fn func(value T) error) func(nex
}

func (a *Authentication[T]) handleError(w http.ResponseWriter, r *http.Request, err error) {
hhe := &HttpHeaderError{}
if errors.As(err, &hhe) {
hhe.WriteTo(w)
for _, err := range multierr.Errors(err) {
var rh WithResponseHeaders
if errors.As(err, &rh) {
for key, value := range rh.ResponseHeaders() {
for _, v := range value {
w.Header().Add(key, v)
}
}
}
}

if errors.Is(err, ErrBadAuthentication) || errors.Is(err, ErrNoAuthentication) {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
} else if a.unknownErrorHandler == nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} else {
statusCode := http.StatusInternalServerError

var rs WithResponseStatus
if errors.As(err, &rs) {
statusCode = rs.ResponseStatus()
} else if errors.Is(err, ErrBadAuthentication) || errors.Is(err, ErrNoAuthentication) {
statusCode = http.StatusUnauthorized
} else if a.unknownErrorHandler != nil {
a.unknownErrorHandler(w, r, err)
return
}

var rw ResponseBodyWriter
if errors.As(err, &rw) {
w.WriteHeader(statusCode)
rw.WriteResponse(w)
} else {
http.Error(w, http.StatusText(statusCode), statusCode)
}
}
23 changes: 23 additions & 0 deletions internal/authn/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package authn

import (
"context"
"errors"
"net/http"

"go.uber.org/multierr"
)

type Authenticator[IN any, OUT any] interface {
Expand Down Expand Up @@ -61,3 +64,23 @@ func Chain4[IN any, MID1 any, MID2 any, MID3 any, OUT any](
}
})
}

func Alternative[A any, B any](authenticators ...Authenticator[A, B]) Authenticator[A, B] {
return AuthenticatorFunc[A, B](func(ctx context.Context, in A) (result B, err error) {
for _, authenticator := range authenticators {
if out, err1 := authenticator.Authenticate(ctx, in); err1 != nil {
multierr.AppendInto(&err, err1)
if errors.Is(err, ErrNoAuthentication) || errors.Is(err, ErrBadAuthentication) {
continue
} else {
break
}
} else {
result = out
err = nil
break
}
}
return
})
}
24 changes: 24 additions & 0 deletions internal/authn/authinfo/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"github.com/glasskube/distr/internal/authn"
"github.com/glasskube/distr/internal/db"
"github.com/glasskube/distr/internal/types"
"github.com/glasskube/distr/internal/util"
)

type DbAuthInfo struct {
Expand Down Expand Up @@ -48,3 +49,26 @@
}, nil
})
}

func AgentDbAuthenticator() authn.Authenticator[AgentAuthInfo, *DbAuthInfo] {
return authn.AuthenticatorFunc[AgentAuthInfo, *DbAuthInfo](func(ctx context.Context, a AgentAuthInfo) (*DbAuthInfo, error) {

Check failure on line 54 in internal/authn/authinfo/db.go

View workflow job for this annotation

GitHub Actions / Build

The line is 125 characters long, which exceeds the maximum of 120 characters. (lll)
userWithRole, org, err := db.GetUserAccountAndOrgForDeploymentTarget(ctx, a.CurrentDeploymentTargetID())
if errors.Is(err, apierrors.ErrNotFound) {
return nil, authn.ErrBadAuthentication
} else if err != nil {
return nil, err
}
return &DbAuthInfo{
AuthInfo: &SimpleAuthInfo{
organizationID: &org.ID,
userID: userWithRole.ID,
userEmail: userWithRole.Email,
emailVerified: userWithRole.EmailVerifiedAt != nil,
userRole: util.PtrTo(userWithRole.UserRole),
rawToken: a.Token(),
},
user: util.PtrTo(userWithRole.AsUserAccount()),
org: org,
}, nil
})
}
35 changes: 35 additions & 0 deletions internal/authn/basic/authenticator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package basic

import (
"context"
"net/http"

"github.com/glasskube/distr/internal/authn"
)

type Auth struct {
Username, Password string
}

func Authenticator() authn.RequestAuthenticator[Auth] {
fn := func(ctx context.Context, r *http.Request) (result Auth, err error) {
if username, password, ok := r.BasicAuth(); !ok {
err = authn.NewHttpHeaderError(
authn.ErrNoAuthentication,
http.Header{
"WWW-Authenticate": []string{`Bearer realm="http://localhost:8585/v2/",service="localhost:8585"`, `Basic realm="Distr"`},

Check failure on line 20 in internal/authn/basic/authenticator.go

View workflow job for this annotation

GitHub Actions / Build

The line is 126 characters long, which exceeds the maximum of 120 characters. (lll)
},
)
} else {
result = Auth{Username: username, Password: password}
}
return
}
return authn.AuthenticatorFunc[*http.Request, Auth](fn)
}

func Password() authn.Authenticator[Auth, string] {
return authn.AuthenticatorFunc[Auth, string](func(ctx context.Context, basic Auth) (string, error) {
return basic.Password, nil
})
}
Loading
Loading