Merge branch '19954-permission-dedup-doc'
[arvados.git] / lib / ctrlctx / auth.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package ctrlctx
6
7 import (
8         "context"
9         "crypto/hmac"
10         "crypto/sha256"
11         "database/sql"
12         "errors"
13         "fmt"
14         "io"
15         "strings"
16         "sync"
17         "time"
18
19         "git.arvados.org/arvados.git/lib/controller/api"
20         "git.arvados.org/arvados.git/sdk/go/arvados"
21         "git.arvados.org/arvados.git/sdk/go/auth"
22         "github.com/ghodss/yaml"
23 )
24
25 var (
26         ErrNoAuthContext   = errors.New("bug: there is no authorization in this context")
27         ErrUnauthenticated = errors.New("unauthenticated request")
28 )
29
30 // WrapCallsWithAuth returns a call wrapper (suitable for assigning to
31 // router.router.WrapCalls) that makes CurrentUser(ctx) et al. work
32 // from inside the wrapped functions.
33 //
34 // The incoming context must come from WrapCallsInTransactions or
35 // NewWithTransaction.
36 func WrapCallsWithAuth(cluster *arvados.Cluster) func(api.RoutableFunc) api.RoutableFunc {
37         var authcache authcache
38         return func(origFunc api.RoutableFunc) api.RoutableFunc {
39                 return func(ctx context.Context, opts interface{}) (_ interface{}, err error) {
40                         var tokens []string
41                         if creds, ok := auth.FromContext(ctx); ok {
42                                 tokens = creds.Tokens
43                         }
44                         return origFunc(context.WithValue(ctx, contextKeyAuth, &authcontext{
45                                 authcache: &authcache,
46                                 cluster:   cluster,
47                                 tokens:    tokens,
48                         }), opts)
49                 }
50         }
51 }
52
53 // CurrentAuth returns the arvados.User whose privileges should be
54 // used in the given context, and the arvados.APIClientAuthorization
55 // the caller presented in order to authenticate the current request.
56 //
57 // Returns ErrUnauthenticated if the current request was not
58 // authenticated (no token provided, token is expired, etc).
59 func CurrentAuth(ctx context.Context) (*arvados.User, *arvados.APIClientAuthorization, error) {
60         ac, ok := ctx.Value(contextKeyAuth).(*authcontext)
61         if !ok {
62                 return nil, nil, ErrNoAuthContext
63         }
64         ac.lookupOnce.Do(func() {
65                 // We only validate/lookup the token once per API
66                 // call, even though authcache should be efficient
67                 // enough to do a lookup each time. This guarantees we
68                 // always return the same result when called multiple
69                 // times in the course of handling a single API call.
70                 for _, token := range ac.tokens {
71                         user, aca, err := ac.authcache.lookup(ctx, ac.cluster, token)
72                         if err != nil {
73                                 ac.err = err
74                                 return
75                         }
76                         if user != nil {
77                                 ac.user, ac.apiClientAuthorization = user, aca
78                                 return
79                         }
80                 }
81                 ac.err = ErrUnauthenticated
82         })
83         return ac.user, ac.apiClientAuthorization, ac.err
84 }
85
86 type contextKeyA string
87
88 var contextKeyAuth = contextKeyT("auth")
89
90 type authcontext struct {
91         authcache              *authcache
92         cluster                *arvados.Cluster
93         tokens                 []string
94         user                   *arvados.User
95         apiClientAuthorization *arvados.APIClientAuthorization
96         err                    error
97         lookupOnce             sync.Once
98 }
99
100 var authcacheTTL = time.Minute
101
102 type authcacheent struct {
103         expireTime             time.Time
104         apiClientAuthorization arvados.APIClientAuthorization
105         user                   arvados.User
106 }
107
108 type authcache struct {
109         mtx         sync.Mutex
110         entries     map[string]*authcacheent
111         nextCleanup time.Time
112 }
113
114 // lookup returns the user and aca info for a given token. Returns nil
115 // if the token is not valid. Returns a non-nil error if there was an
116 // unexpected error from the database, etc.
117 func (ac *authcache) lookup(ctx context.Context, cluster *arvados.Cluster, token string) (*arvados.User, *arvados.APIClientAuthorization, error) {
118         ac.mtx.Lock()
119         ent := ac.entries[token]
120         ac.mtx.Unlock()
121         if ent != nil && ent.expireTime.After(time.Now()) {
122                 return &ent.user, &ent.apiClientAuthorization, nil
123         }
124         if token == "" {
125                 return nil, nil, nil
126         }
127         tx, err := CurrentTx(ctx)
128         if err != nil {
129                 return nil, nil, err
130         }
131         var aca arvados.APIClientAuthorization
132         var user arvados.User
133
134         var cond string
135         var args []interface{}
136         if len(token) > 30 && strings.HasPrefix(token, "v2/") && token[30] == '/' {
137                 fields := strings.Split(token, "/")
138                 cond = `aca.uuid = $1 and aca.api_token = $2`
139                 args = []interface{}{fields[1], fields[2]}
140         } else {
141                 // Bare token or OIDC access token
142                 mac := hmac.New(sha256.New, []byte(cluster.SystemRootToken))
143                 io.WriteString(mac, token)
144                 hmac := fmt.Sprintf("%x", mac.Sum(nil))
145                 cond = `aca.api_token in ($1, $2)`
146                 args = []interface{}{token, hmac}
147         }
148         var expiresAt sql.NullTime
149         var scopesYAML []byte
150         err = tx.QueryRowContext(ctx, `
151 select aca.uuid, aca.expires_at, aca.api_token, aca.scopes, users.uuid, users.is_active, users.is_admin
152  from api_client_authorizations aca
153  left join users on aca.user_id = users.id
154  where `+cond+`
155  and (expires_at is null or expires_at > current_timestamp at time zone 'UTC')`, args...).Scan(
156                 &aca.UUID, &expiresAt, &aca.APIToken, &scopesYAML,
157                 &user.UUID, &user.IsActive, &user.IsAdmin)
158         if err == sql.ErrNoRows {
159                 return nil, nil, nil
160         } else if err != nil {
161                 return nil, nil, err
162         }
163         aca.ExpiresAt = expiresAt.Time
164         if len(scopesYAML) > 0 {
165                 err = yaml.Unmarshal(scopesYAML, &aca.Scopes)
166                 if err != nil {
167                         return nil, nil, fmt.Errorf("loading scopes for %s: %w", aca.UUID, err)
168                 }
169         }
170         ent = &authcacheent{
171                 expireTime:             time.Now().Add(authcacheTTL),
172                 apiClientAuthorization: aca,
173                 user:                   user,
174         }
175         ac.mtx.Lock()
176         defer ac.mtx.Unlock()
177         if ac.entries == nil {
178                 ac.entries = map[string]*authcacheent{}
179         }
180         if ac.nextCleanup.IsZero() || ac.nextCleanup.Before(time.Now()) {
181                 for token, ent := range ac.entries {
182                         if !ent.expireTime.After(time.Now()) {
183                                 delete(ac.entries, token)
184                         }
185                 }
186                 ac.nextCleanup = time.Now().Add(authcacheTTL)
187         }
188         ac.entries[token] = ent
189         return &ent.user, &ent.apiClientAuthorization, nil
190 }