Skip to content
Merged
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
6 changes: 6 additions & 0 deletions src/constants/claims_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ var ClaimCategoryMap = map[string]ClaimCategory{
UPDATE_API_KEY_CLAIM: {ClaimGroupAdministration, "API Key", ClaimActionUpdate},
DELETE_API_KEY_CLAIM: {ClaimGroupAdministration, "API Key", ClaimActionDelete},

// ── Administration › API Key (Own) ────────────────────────────────────
LIST_OWN_API_KEY_CLAIM: {ClaimGroupAdministration, "API Key (Own)", ClaimActionRead},
CREATE_OWN_API_KEY_CLAIM: {ClaimGroupAdministration, "API Key (Own)", ClaimActionCreate},
UPDATE_OWN_API_KEY_CLAIM: {ClaimGroupAdministration, "API Key (Own)", ClaimActionUpdate},
DELETE_OWN_API_KEY_CLAIM: {ClaimGroupAdministration, "API Key (Own)", ClaimActionDelete},

// ── Administration › Role ─────────────────────────────────────────────
LIST_ROLE_CLAIM: {ClaimGroupAdministration, "Role", ClaimActionRead},
CREATE_ROLE_CLAIM: {ClaimGroupAdministration, "Role", ClaimActionCreate},
Expand Down
22 changes: 22 additions & 0 deletions src/constants/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const (
ENABLE_CREDENTIALS_OBFUSCATION_DEFAULT_VALUE = true

DISABLE_URL_VALIDATION_ENV_VAR = "DISABLE_URL_VALIDATION"
URL_WHITELIST_ENV_VAR = "URL_WHITELIST"
API_MODE = "api"
CLI_MODE = "cli"
ORCHESTRATOR_MODE = "orchestrator"
Expand Down Expand Up @@ -205,6 +206,10 @@ const (
)

const (
ROOT_USER_EMAIL = "root@localhost"
ROOT_USER_NAME = "root"
ROOT_USER_ID = "root"

USER_ROLE = "USER"
ADMIN_ROLE = "ADMIN"
SUPER_USER_ROLE = "SUPER_USER"
Expand All @@ -225,6 +230,11 @@ const (
DELETE_API_KEY_CLAIM = "DELETE_API_KEY"
UPDATE_API_KEY_CLAIM = "UPDATE_API_KEY"

LIST_OWN_API_KEY_CLAIM = "LIST_OWN_API_KEY"
CREATE_OWN_API_KEY_CLAIM = "CREATE_OWN_API_KEY"
DELETE_OWN_API_KEY_CLAIM = "DELETE_OWN_API_KEY"
UPDATE_OWN_API_KEY_CLAIM = "UPDATE_OWN_API_KEY"

LIST_CLAIM_CLAIM = "LIST_CLAIM"
CREATE_CLAIM_CLAIM = "CREATE_CLAIM"
DELETE_CLAIM_CLAIM = "DELETE_CLAIM"
Expand Down Expand Up @@ -332,6 +342,10 @@ var AllSystemClaims = []string{
CREATE_API_KEY_CLAIM,
DELETE_API_KEY_CLAIM,
UPDATE_API_KEY_CLAIM,
LIST_OWN_API_KEY_CLAIM,
CREATE_OWN_API_KEY_CLAIM,
DELETE_OWN_API_KEY_CLAIM,
UPDATE_OWN_API_KEY_CLAIM,
LIST_CLAIM_CLAIM,
CREATE_CLAIM_CLAIM,
DELETE_CLAIM_CLAIM,
Expand Down Expand Up @@ -417,6 +431,10 @@ var AllSuperUserClaims = []string{
CREATE_API_KEY_CLAIM,
DELETE_API_KEY_CLAIM,
UPDATE_API_KEY_CLAIM,
LIST_OWN_API_KEY_CLAIM,
CREATE_OWN_API_KEY_CLAIM,
DELETE_OWN_API_KEY_CLAIM,
UPDATE_OWN_API_KEY_CLAIM,
LIST_CLAIM_CLAIM,
CREATE_CLAIM_CLAIM,
DELETE_CLAIM_CLAIM,
Expand Down Expand Up @@ -533,4 +551,8 @@ var DefaultClaims = []string{
DELETE_OWN_VM_SNAPSHOT_CLAIM,
DELETE_ALL_OWN_VM_SNAPSHOTS_CLAIM,
REVERT_OWN_VM_SNAPSHOT_CLAIM,
LIST_OWN_API_KEY_CLAIM,
CREATE_OWN_API_KEY_CLAIM,
DELETE_OWN_API_KEY_CLAIM,
UPDATE_OWN_API_KEY_CLAIM,
}
51 changes: 51 additions & 0 deletions src/controllers/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,35 @@ import (
"github.com/gorilla/mux"
)

func enrichApiKeyWithUser(ctx basecontext.ApiContext, apiKey *models.ApiKeyResponse) {
if apiKey.UserID == "" {
return
}

dbService, err := serviceprovider.GetDatabaseService(ctx)
if err != nil {
return
}

user, err := dbService.GetUser(ctx, apiKey.UserID)
if err != nil {
return
}

if user != nil {
apiKey.UserEmail = user.Email
apiKey.UserName = user.Name
apiKey.UserUsername = user.Username
}
}

func registerApiKeysHandlers(ctx basecontext.ApiContext, version string) {
ctx.LogInfof("Registering version %s ApiKeys handlers", version)
restapi.NewController().
WithMethod(restapi.GET).
WithVersion(version).WithPath("/auth/api_keys").
WithRequiredClaim(constants.LIST_API_KEY_CLAIM).
WithRequiredClaim(constants.LIST_OWN_API_KEY_CLAIM).
WithHandler(GetApiKeysHandler()).
Register()

Expand All @@ -32,6 +55,7 @@ func registerApiKeysHandlers(ctx basecontext.ApiContext, version string) {
WithVersion(version).
WithPath("/auth/api_keys/{id}").
WithRequiredClaim(constants.LIST_API_KEY_CLAIM).
WithRequiredClaim(constants.LIST_OWN_API_KEY_CLAIM).
WithHandler(GetApiKeyHandler()).
Register()

Expand All @@ -40,6 +64,7 @@ func registerApiKeysHandlers(ctx basecontext.ApiContext, version string) {
WithVersion(version).
WithPath("/auth/api_keys").
WithRequiredClaim(constants.CREATE_API_KEY_CLAIM).
WithRequiredClaim(constants.CREATE_OWN_API_KEY_CLAIM).
WithHandler(CreateApiKeyHandler()).
Register()

Expand All @@ -48,6 +73,7 @@ func registerApiKeysHandlers(ctx basecontext.ApiContext, version string) {
WithVersion(version).
WithPath("/auth/api_keys/{id}").
WithRequiredClaim(constants.DELETE_API_KEY_CLAIM).
WithRequiredClaim(constants.DELETE_OWN_API_KEY_CLAIM).
WithHandler(DeleteApiKeyHandler()).
Register()

Expand Down Expand Up @@ -113,6 +139,25 @@ func CreateApiKeyHandler() restapi.ControllerHandler {

dtoApiKey := mappers.ApiKeyRequestToDto(request)

authContext := ctx.GetAuthorizationContext()
if authContext != nil && authContext.User != nil {
hasFullCreateClaim := authContext.HasEffectiveClaim(constants.CREATE_API_KEY_CLAIM)
hasOwnCreateClaim := authContext.HasEffectiveClaim(constants.CREATE_OWN_API_KEY_CLAIM)

if hasOwnCreateClaim {
// Users with CREATE_OWN_API_KEY_CLAIM can only create for themselves
if !hasFullCreateClaim && request.UserID != "" && request.UserID != authContext.User.ID {
createApiKeyDiag.AddError(strconv.Itoa(http.StatusForbidden), "You do not have permission to create API keys for other users", "Validation")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(createApiKeyDiag, http.StatusForbidden))
return
}
// Auto-assign to user's ID if not provided
if dtoApiKey.UserID == "" {
dtoApiKey.UserID = authContext.User.ID
}
}
}

dtoApiKeyResult, err := dbService.CreateApiKey(ctx, dtoApiKey)
if err != nil {
rsp := models.NewFromError(err)
Expand Down Expand Up @@ -164,6 +209,10 @@ func GetApiKeysHandler() restapi.ControllerHandler {

result := mappers.ApiKeysDtoToApiKeyResponse(dtoApiKeys)

for i := range result {
enrichApiKeyWithUser(ctx, &result[i])
}

w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(result)
ctx.LogInfof("Api Keys returned successfully")
Expand Down Expand Up @@ -251,6 +300,8 @@ func GetApiKeyHandler() restapi.ControllerHandler {

response := mappers.ApiKeyDtoToApiKeyResponse(*dtoApiKey)

enrichApiKeyWithUser(ctx, &response)

w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(response)
ctx.LogInfof("Api Key returned successfully")
Expand Down
112 changes: 86 additions & 26 deletions src/controllers/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import (
"strconv"

"github.com/Parallels/prl-devops-service/basecontext"
"github.com/Parallels/prl-devops-service/constants"
dbmodels "github.com/Parallels/prl-devops-service/data/models"
"github.com/Parallels/prl-devops-service/errors"
"github.com/Parallels/prl-devops-service/mappers"
"github.com/Parallels/prl-devops-service/models"
models "github.com/Parallels/prl-devops-service/models"
"github.com/Parallels/prl-devops-service/restapi"
"github.com/Parallels/prl-devops-service/security/apikey"
bruteforceguard "github.com/Parallels/prl-devops-service/security/brute_force_guard"
"github.com/Parallels/prl-devops-service/security/jwt"
"github.com/Parallels/prl-devops-service/security/password"
Expand Down Expand Up @@ -70,32 +73,78 @@ func GetTokenHandler() restapi.ControllerHandler {
return
}

user, err := dbService.GetUser(ctx, request.Email)
if err != nil {
rsp := models.NewFromError(err)
getTokenDiag.AddError(strconv.Itoa(rsp.Code), rsp.Message, "GetUser")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, rsp.Code))
return
var idOrEmail string
if request.Email != "" {
idOrEmail = request.Email
} else if request.Username != "" {
idOrEmail = request.Username
} else {
idOrEmail = "" // fallback, shouldn't happen due to validation
}

if user == nil {
getTokenDiag.AddError(strconv.Itoa(http.StatusUnauthorized), "Invalid User or Password", "GetUser")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, http.StatusUnauthorized))
return
var user *dbmodels.User
var apiKeyId string

if request.ApiKey != "" {
result, err := apikey.ValidateApiKey(ctx, dbService, request.ApiKey)
if err != nil {
if apiErr, ok := err.(*apikey.ApiKeyValidationError); ok {
getTokenDiag.AddError(strconv.Itoa(apiErr.Code), err.Error(), apiErr.Component)
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, apiErr.Code))
} else {
rsp := models.NewFromError(err)
getTokenDiag.AddError(strconv.Itoa(rsp.Code), rsp.Message, "ValidateApiKey")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, rsp.Code))
}
return
}

apiKeyId = result.ApiKeyId
if result.UserID != "" {
user, err = dbService.GetUser(ctx, result.UserID)
if err != nil || user == nil {
getTokenDiag.AddError(strconv.Itoa(http.StatusInternalServerError), "User not found", "GetUser")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, http.StatusInternalServerError))
return
}
} else {
user, err = dbService.GetUser(ctx, constants.ROOT_USER_ID)
if err != nil {
getTokenDiag.AddError(strconv.Itoa(http.StatusInternalServerError), "Root user not found", "GetRootUser")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, http.StatusInternalServerError))
return
}
}
} else {
user, err = dbService.GetUser(ctx, idOrEmail)
if err != nil {
rsp := models.NewFromError(err)
getTokenDiag.AddError(strconv.Itoa(rsp.Code), rsp.Message, "GetUser")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, rsp.Code))
return
}

if user == nil {
getTokenDiag.AddError(strconv.Itoa(http.StatusUnauthorized), "Invalid User or Password", "GetUser")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, http.StatusUnauthorized))
return
}
}

bruteForceSvc := bruteforceguard.Get()

passwdSvc := password.Get()
if err := passwdSvc.Compare(request.Password, user.ID, user.Password); err != nil {
rsp := models.NewFromError(err)
getTokenDiag.AddError(strconv.Itoa(rsp.Code), rsp.Message, "Compare")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, rsp.Code))
if request.ApiKey == "" {
if err := passwdSvc.Compare(request.Password, user.ID, user.Password); err != nil {
rsp := models.NewFromError(err)
getTokenDiag.AddError(strconv.Itoa(rsp.Code), rsp.Message, "Compare")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, rsp.Code))

if diag := bruteForceSvc.Process(user.ID, false, "Invalid Password"); diag.HasErrors() {
ctx.LogErrorf("Error processing brute force guard: %v", diag)
if diag := bruteForceSvc.Process(user.ID, false, "Invalid Password"); diag.HasErrors() {
ctx.LogErrorf("Error processing brute force guard: %v", diag)
}
return
}
return
}

userRoles := make([]string, 0)
Expand All @@ -113,14 +162,19 @@ func GetTokenHandler() restapi.ControllerHandler {
"roles": userRoles,
"claims": userClaims,
}
if apiKeyId != "" {
claims["api_key_id"] = apiKeyId
}
tokenSvc := jwt.Get()
tokenStr, err := tokenSvc.Sign(claims)
if err != nil {
rsp := models.NewFromError(err)
getTokenDiag.AddError(strconv.Itoa(rsp.Code), rsp.Message, "Sign")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, rsp.Code))
if diag := bruteForceSvc.Process(user.ID, false, err.Error()); diag.HasErrors() {
ctx.LogErrorf("Error processing brute force guard: %v", diag)
if request.ApiKey == "" {
if diag := bruteForceSvc.Process(user.ID, false, err.Error()); diag.HasErrors() {
ctx.LogErrorf("Error processing brute force guard: %v", diag)
}
}
return
}
Expand All @@ -129,25 +183,31 @@ func GetTokenHandler() restapi.ControllerHandler {
rsp := models.NewFromError(err)
getTokenDiag.AddError(strconv.Itoa(rsp.Code), rsp.Message, "Parse")
ReturnApiErrorWithDiagnostics(ctx, w, models.NewDiagnosticsWithCode(getTokenDiag, rsp.Code))
if diag := bruteForceSvc.Process(user.ID, false, err.Error()); diag.HasErrors() {
ctx.LogErrorf("Error processing brute force guard: %v", diag)
if request.ApiKey == "" {
if diag := bruteForceSvc.Process(user.ID, false, err.Error()); diag.HasErrors() {
ctx.LogErrorf("Error processing brute force guard: %v", diag)
}
}
return
}

responseEmail := user.Email

response := models.LoginResponse{
Token: tokenStr,
Email: request.Email,
Email: responseEmail,
ExpiresAt: int64(token.Claims["exp"].(float64)),
}

if diag := bruteForceSvc.Process(user.ID, true, "Success"); diag.HasErrors() {
ctx.LogErrorf("Error processing brute force guard: %v", diag)
if request.ApiKey == "" {
if diag := bruteForceSvc.Process(user.ID, true, "Success"); diag.HasErrors() {
ctx.LogErrorf("Error processing brute force guard: %v", diag)
}
}

w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(response)
ctx.LogInfof("User %s logged in", request.Email)
ctx.LogInfof("User %s logged in", responseEmail)
}
}

Expand Down
Loading
Loading