mirror of
https://github.com/grafana/grafana.git
synced 2025-02-15 10:03:33 -06:00
* Moving POC files from #64283 to a new branch
Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
* Adding missing permission definition
Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
* Force the service instantiation while client isn't merged
Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
* Merge conf with main
Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
* Leave go-sqlite3 version unchanged
Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
* tidy
Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
* User SearchUserPermissions instead of SearchUsersPermissions
* Replace DummyKeyService with signingkeys.Service
* Use user🆔<id> as subject
* Fix introspection endpoint issue
* Add X-Grafana-Org-Id to get_resources.bash script
* Regenerate toggles_gen.go
* Fix basic.go
* Add GetExternalService tests
* Add GetPublicKeyScopes tests
* Add GetScopesOnUser tests
* Add GetScopes tests
* Add ParsePublicKeyPem tests
* Add database test for GetByName
* re-add comments
* client tests added
* Add GetExternalServicePublicKey tests
* Add other test case to GetExternalServicePublicKey
* client_credentials grant test
* Add test to jwtbearer grant
* Test Comments
* Add handleKeyOptions tests
* Add RSA key generation test
* Add ECDSA by default to EmbeddedSigningKeysService
* Clean up org id scope and audiences
* Add audiences to the DB
* Fix check on Audience
* Fix double import
* Add AC Store mock and align oauthserver tests
* Fix test after rebase
* Adding missing store function to mock
* Fix double import
* Add CODEOWNER
* Fix some linting errors
* errors don't need type assertion
* Typo codeowners
* use mockery for oauthserver store
* Add feature toggle check
* Fix db tests to handle the feature flag
* Adding call to DeleteExternalServiceRole
* Fix flaky test
* Re-organize routes comments and plan futur work
* Add client_id check to Extended JWT client
* Clean up
* Fix
* Remove background service registry instantiation of the OAuth server
* Comment cleanup
* Remove unused client function
* Update go.mod to use the latest ory/fosite commit
* Remove oauth2_server related configs from defaults.ini
* Add audiences to DTO
* Fix flaky test
* Remove registration endpoint and demo scripts. Document code
* Rename packages
* Remove the OAuthService vs OAuthServer confusion
* fix incorrect import ext_jwt_test
* Comments and order
* Comment basic auth
* Remove unecessary todo
* Clean api
* Moving ParsePublicKeyPem to utils
* re ordering functions in service.go
* Fix comment
* comment on the redirect uri
* Add RBAC actions, not only scopes
* Fix tests
* re-import featuremgmt in migrations
* Fix wire
* Fix scopes in test
* Fix flaky test
* Remove todo, the intersection should always return the minimal set
* Remove unecessary check from intersection code
* Allow env overrides on settings
* remove the term app name
* Remove app keyword for client instead and use Name instead of ExternalServiceName
* LogID remove ExternalService ref
* Use Name instead of ExternalServiceName
* Imports order
* Inline
* Using ExternalService and ExternalServiceDTO
* Remove xorm tags
* comment
* Rename client files
* client -> external service
* comments
* Move test to correct package
* slimmer test
* cachedUser -> cachedExternalService
* Fix aggregate store test
* PluginAuthSession -> AuthSession
* Revert the nil cehcks
* Remove unecessary extra
* Removing custom session
* fix typo in test
* Use constants for tests
* Simplify HandleToken tests
* Refactor the HandleTokenRequest test
* test message
* Review test
* Prevent flacky test on client as well
* go imports
* Revert changes from 526e48ad45
* AuthN: Change the External Service registration form (#68649)
* AuthN: change the External Service registration form
* Gen default permissions
* Change demo script registration form
* Remove unecessary comment
* Nit.
* Reduce cyclomatic complexity
* Remove demo_scripts
* Handle case with no service account
* Comments
* Group key gen
* Nit.
* Check the SaveExternalService test
* Rename cachedUser to cachedClient in test
* One more test case to database test
* Comments
* Remove last org scope
Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
* Update pkg/services/oauthserver/utils/utils_test.go
* Update pkg/services/sqlstore/migrations/oauthserver/migrations.go
Remove comment
* Update pkg/setting/setting.go
Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
---------
Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
352 lines
12 KiB
Go
352 lines
12 KiB
Go
package oasimpl
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/ory/fosite"
|
|
"github.com/ory/fosite/handler/oauth2"
|
|
|
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
|
"github.com/grafana/grafana/pkg/services/oauthserver"
|
|
"github.com/grafana/grafana/pkg/services/oauthserver/utils"
|
|
"github.com/grafana/grafana/pkg/services/team"
|
|
"github.com/grafana/grafana/pkg/services/user"
|
|
)
|
|
|
|
// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization
|
|
// grant (ex: client_credentials, jwtbearer)
|
|
func (s *OAuth2ServiceImpl) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {
|
|
// This context will be passed to all methods.
|
|
ctx := req.Context()
|
|
|
|
// Create an empty session object which will be passed to the request handlers
|
|
oauthSession := NewAuthSession()
|
|
|
|
// This will create an access request object and iterate through the registered TokenEndpointHandlers to validate the request.
|
|
accessRequest, err := s.oauthProvider.NewAccessRequest(ctx, req, oauthSession)
|
|
if err != nil {
|
|
s.writeAccessError(ctx, rw, accessRequest, err)
|
|
return
|
|
}
|
|
|
|
client, err := s.GetExternalService(ctx, accessRequest.GetClient().GetID())
|
|
if err != nil || client == nil {
|
|
s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, &fosite.RFC6749Error{
|
|
DescriptionField: "Could not find the requested subject.",
|
|
ErrorField: "not_found",
|
|
CodeField: http.StatusBadRequest,
|
|
})
|
|
return
|
|
}
|
|
oauthSession.JWTClaims.Add("client_id", client.ClientID)
|
|
|
|
errClientCred := s.handleClientCredentials(ctx, accessRequest, oauthSession, client)
|
|
if errClientCred != nil {
|
|
s.writeAccessError(ctx, rw, accessRequest, errClientCred)
|
|
return
|
|
}
|
|
|
|
errJWTBearer := s.handleJWTBearer(ctx, accessRequest, oauthSession, client)
|
|
if errJWTBearer != nil {
|
|
s.writeAccessError(ctx, rw, accessRequest, errJWTBearer)
|
|
return
|
|
}
|
|
|
|
// All tokens we generate in this service should target Grafana's API.
|
|
accessRequest.GrantAudience(s.cfg.AppURL)
|
|
|
|
// Prepare response, fosite handlers will populate the token.
|
|
response, err := s.oauthProvider.NewAccessResponse(ctx, accessRequest)
|
|
if err != nil {
|
|
s.writeAccessError(ctx, rw, accessRequest, err)
|
|
return
|
|
}
|
|
s.oauthProvider.WriteAccessResponse(ctx, rw, accessRequest, response)
|
|
}
|
|
|
|
// writeAccessError logs the error then uses fosite to write the error back to the user.
|
|
func (s *OAuth2ServiceImpl) writeAccessError(ctx context.Context, rw http.ResponseWriter, accessRequest fosite.AccessRequester, err error) {
|
|
var fositeErr *fosite.RFC6749Error
|
|
if errors.As(err, &fositeErr) {
|
|
s.logger.Error("description", fositeErr.DescriptionField, "hint", fositeErr.HintField, "error", fositeErr.ErrorField)
|
|
} else {
|
|
s.logger.Error("error", err)
|
|
}
|
|
s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, err)
|
|
}
|
|
|
|
// splitOAuthScopes sort scopes that are generic (profile, email, groups, entitlements) from scopes
|
|
// that are RBAC actions (used to further restrict the entitlements embedded in the access_token)
|
|
func splitOAuthScopes(requestedScopes fosite.Arguments) (map[string]bool, map[string]bool) {
|
|
actionsFilter := map[string]bool{}
|
|
claimsFilter := map[string]bool{}
|
|
for _, scope := range requestedScopes {
|
|
switch scope {
|
|
case "profile", "email", "groups", "entitlements":
|
|
claimsFilter[scope] = true
|
|
default:
|
|
actionsFilter[scope] = true
|
|
}
|
|
}
|
|
return actionsFilter, claimsFilter
|
|
}
|
|
|
|
// handleJWTBearer populates the "impersonation" access_token generated by fosite to match the rfc9068 specifications (entitlements, groups).
|
|
// It ensures that the user can be impersonated, that the generated token audiences only contain Grafana's AppURL (and token endpoint)
|
|
// and that entitlements solely contain the user's permissions that the client is allowed to have.
|
|
func (s *OAuth2ServiceImpl) handleJWTBearer(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.ExternalService) error {
|
|
if !accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeJWTBearer)) {
|
|
return nil
|
|
}
|
|
|
|
userID, err := utils.ParseUserIDFromSubject(oauthSession.Subject)
|
|
if err != nil {
|
|
return &fosite.RFC6749Error{
|
|
DescriptionField: "Could not find the requested subject.",
|
|
ErrorField: "not_found",
|
|
CodeField: http.StatusBadRequest,
|
|
}
|
|
}
|
|
|
|
// Check audiences list only contains the AppURL and the token endpoint
|
|
for _, aud := range accessRequest.GetGrantedAudience() {
|
|
if aud != fmt.Sprintf("%voauth2/token", s.cfg.AppURL) && aud != s.cfg.AppURL {
|
|
return &fosite.RFC6749Error{
|
|
DescriptionField: "Client is not allowed to target this Audience.",
|
|
HintField: "The audience must be the AppURL or the token endpoint.",
|
|
ErrorField: "invalid_request",
|
|
CodeField: http.StatusForbidden,
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the client was not allowed to impersonate the user we would not have reached this point given allowed scopes would have been empty
|
|
// But just in case we check again
|
|
ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10)))
|
|
hasAccess, errAccess := s.accessControl.Evaluate(ctx, client.SignedInUser, ev)
|
|
if errAccess != nil || !hasAccess {
|
|
return &fosite.RFC6749Error{
|
|
DescriptionField: "Client is not allowed to impersonate subject.",
|
|
ErrorField: "restricted_access",
|
|
CodeField: http.StatusForbidden,
|
|
}
|
|
}
|
|
|
|
// Populate claims' suject from the session subject
|
|
oauthSession.JWTClaims.Subject = oauthSession.Subject
|
|
|
|
// Get the user
|
|
query := user.GetUserByIDQuery{ID: userID}
|
|
dbUser, err := s.userService.GetByID(ctx, &query)
|
|
if err != nil {
|
|
if errors.Is(err, user.ErrUserNotFound) {
|
|
return &fosite.RFC6749Error{
|
|
DescriptionField: "Could not find the requested subject.",
|
|
ErrorField: "not_found",
|
|
CodeField: http.StatusBadRequest,
|
|
}
|
|
}
|
|
return &fosite.RFC6749Error{
|
|
DescriptionField: "The request subject could not be processed.",
|
|
ErrorField: "server_error",
|
|
CodeField: http.StatusInternalServerError,
|
|
}
|
|
}
|
|
oauthSession.Username = dbUser.Login
|
|
|
|
// Split scopes into actions and claims
|
|
actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes())
|
|
|
|
teams := []*team.TeamDTO{}
|
|
// Fetch teams if the groups scope is requested or if we need to populate it in the entitlements
|
|
if claimsFilter["groups"] ||
|
|
(claimsFilter["entitlements"] && (len(actionsFilter) == 0 || actionsFilter["teams:read"])) {
|
|
var errGetTeams error
|
|
teams, errGetTeams = s.teamService.GetTeamsByUser(ctx, &team.GetTeamsByUserQuery{
|
|
OrgID: oauthserver.TmpOrgID,
|
|
UserID: dbUser.ID,
|
|
// Fetch teams without restriction on permissions
|
|
SignedInUser: &user.SignedInUser{
|
|
OrgID: oauthserver.TmpOrgID,
|
|
Permissions: map[int64]map[string][]string{
|
|
oauthserver.TmpOrgID: {
|
|
ac.ActionTeamsRead: {ac.ScopeTeamsAll},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if errGetTeams != nil {
|
|
return &fosite.RFC6749Error{
|
|
DescriptionField: "The teams scope could not be processed.",
|
|
ErrorField: "server_error",
|
|
CodeField: http.StatusInternalServerError,
|
|
}
|
|
}
|
|
}
|
|
if claimsFilter["profile"] {
|
|
oauthSession.JWTClaims.Add("name", dbUser.Name)
|
|
oauthSession.JWTClaims.Add("login", dbUser.Login)
|
|
oauthSession.JWTClaims.Add("updated_at", dbUser.Updated.Unix())
|
|
}
|
|
if claimsFilter["email"] {
|
|
oauthSession.JWTClaims.Add("email", dbUser.Email)
|
|
}
|
|
if claimsFilter["groups"] {
|
|
teamNames := make([]string, 0, len(teams))
|
|
for _, team := range teams {
|
|
teamNames = append(teamNames, team.Name)
|
|
}
|
|
oauthSession.JWTClaims.Add("groups", teamNames)
|
|
}
|
|
|
|
if claimsFilter["entitlements"] {
|
|
// Get the user permissions (apply the actions filter)
|
|
permissions, errGetPermission := s.filteredUserPermissions(ctx, userID, actionsFilter)
|
|
if errGetPermission != nil {
|
|
return errGetPermission
|
|
}
|
|
|
|
// Compute the impersonated permissions (apply the actions filter, replace the scope self with the user id)
|
|
impPerms := s.filteredImpersonatePermissions(client.ImpersonatePermissions, userID, teams, actionsFilter)
|
|
|
|
// Intersect the permissions with the client permissions
|
|
intesect := ac.Intersect(permissions, impPerms)
|
|
|
|
oauthSession.JWTClaims.Add("entitlements", intesect)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// filteredUserPermissions gets the user permissions and applies the actions filter
|
|
func (s *OAuth2ServiceImpl) filteredUserPermissions(ctx context.Context, userID int64, actionsFilter map[string]bool) ([]ac.Permission, error) {
|
|
permissions, err := s.acService.SearchUserPermissions(ctx, oauthserver.TmpOrgID, ac.SearchOptions{UserID: userID})
|
|
if err != nil {
|
|
return nil, &fosite.RFC6749Error{
|
|
DescriptionField: "The permissions scope could not be processed.",
|
|
ErrorField: "server_error",
|
|
CodeField: http.StatusInternalServerError,
|
|
}
|
|
}
|
|
|
|
// Apply the actions filter
|
|
if len(actionsFilter) > 0 {
|
|
filtered := []ac.Permission{}
|
|
for i := range permissions {
|
|
if actionsFilter[permissions[i].Action] {
|
|
filtered = append(filtered, permissions[i])
|
|
}
|
|
}
|
|
permissions = filtered
|
|
}
|
|
return permissions, nil
|
|
}
|
|
|
|
// filteredImpersonatePermissions computes the impersonated permissions.
|
|
// It applies the actions filter and replaces the "self RBAC scopes" (~ scope templates) by the correct user id/team id.
|
|
func (*OAuth2ServiceImpl) filteredImpersonatePermissions(impersonatePermissions []ac.Permission, userID int64, teams []*team.TeamDTO, actionsFilter map[string]bool) []ac.Permission {
|
|
// Compute the impersonated permissions
|
|
impPerms := impersonatePermissions
|
|
// Apply the actions filter
|
|
if len(actionsFilter) > 0 {
|
|
filtered := []ac.Permission{}
|
|
for i := range impPerms {
|
|
if actionsFilter[impPerms[i].Action] {
|
|
filtered = append(filtered, impPerms[i])
|
|
}
|
|
}
|
|
impPerms = filtered
|
|
}
|
|
|
|
// Replace the scope self with the user id
|
|
correctScopes := []ac.Permission{}
|
|
for i := range impPerms {
|
|
switch impPerms[i].Scope {
|
|
case oauthserver.ScopeGlobalUsersSelf:
|
|
correctScopes = append(correctScopes, ac.Permission{
|
|
Action: impPerms[i].Action,
|
|
Scope: ac.Scope("global.users", "id", strconv.FormatInt(userID, 10)),
|
|
})
|
|
case oauthserver.ScopeUsersSelf:
|
|
correctScopes = append(correctScopes, ac.Permission{
|
|
Action: impPerms[i].Action,
|
|
Scope: ac.Scope("users", "id", strconv.FormatInt(userID, 10)),
|
|
})
|
|
case oauthserver.ScopeTeamsSelf:
|
|
for t := range teams {
|
|
correctScopes = append(correctScopes, ac.Permission{
|
|
Action: impPerms[i].Action,
|
|
Scope: ac.Scope("teams", "id", strconv.FormatInt(teams[t].ID, 10)),
|
|
})
|
|
}
|
|
default:
|
|
correctScopes = append(correctScopes, impPerms[i])
|
|
}
|
|
continue
|
|
}
|
|
return correctScopes
|
|
}
|
|
|
|
// handleClientCredentials populates the client's access_token generated by fosite to match the rfc9068 specifications (entitlements, groups)
|
|
func (s *OAuth2ServiceImpl) handleClientCredentials(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.ExternalService) error {
|
|
if !accessRequest.GetGrantTypes().ExactOne("client_credentials") {
|
|
return nil
|
|
}
|
|
// Set the subject to the service account associated to the client
|
|
oauthSession.JWTClaims.Subject = fmt.Sprintf("user:id:%d", client.ServiceAccountID)
|
|
|
|
sa := client.SignedInUser
|
|
if sa == nil {
|
|
return &fosite.RFC6749Error{
|
|
DescriptionField: "Could not find the service account of the client",
|
|
ErrorField: "not_found",
|
|
CodeField: http.StatusNotFound,
|
|
}
|
|
}
|
|
oauthSession.Username = sa.Login
|
|
|
|
// For client credentials, scopes are not marked as granted by fosite but the request would have been rejected
|
|
// already if the client was not allowed to request them
|
|
for _, scope := range accessRequest.GetRequestedScopes() {
|
|
accessRequest.GrantScope(scope)
|
|
}
|
|
|
|
// Split scopes into actions and claims
|
|
actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes())
|
|
|
|
if claimsFilter["profile"] {
|
|
oauthSession.JWTClaims.Add("name", sa.Name)
|
|
oauthSession.JWTClaims.Add("login", sa.Login)
|
|
}
|
|
if claimsFilter["email"] {
|
|
s.logger.Debug("Service accounts have no emails")
|
|
}
|
|
if claimsFilter["groups"] {
|
|
s.logger.Debug("Service accounts have no groups")
|
|
}
|
|
if claimsFilter["entitlements"] {
|
|
s.logger.Debug("Processing client entitlements")
|
|
if sa.Permissions != nil && sa.Permissions[oauthserver.TmpOrgID] != nil {
|
|
perms := sa.Permissions[oauthserver.TmpOrgID]
|
|
if len(actionsFilter) > 0 {
|
|
filtered := map[string][]string{}
|
|
for action := range actionsFilter {
|
|
if _, ok := perms[action]; ok {
|
|
filtered[action] = perms[action]
|
|
}
|
|
}
|
|
perms = filtered
|
|
}
|
|
oauthSession.JWTClaims.Add("entitlements", perms)
|
|
} else {
|
|
s.logger.Debug("Client has no permissions")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|