Files
Vishal Dalwadi 132aee0bb3 NM-341-v6: AMB Multi-tenancy Management (#4101)
* wip(go): return auth token with user on create org owner;

* wip(go): prevent use of default tenant slug ;

* wip(go): upsert settings for tenant in ctx;

* wip(go): use passed ctx when updating user; remove password validation from ValidateUser (already validated in UpdateUser);

* wip(go): qualify all tenant id usages with table name;

* wip(go): use passed ctx

* wip(go): return old enrollment key type on create;

* wip(go): prevent null networks return;

* wip(go): wire ctx in dns/nameserver functions; wire ctx in host registration functions;

* wip(go): wire ctx in node functions;

* wip(go): wire ctx in node functions;

* wip(go): wire ctx in extclient functions;

* wip(go): remove not needed field;

* wip(go): use passed ctx;

* wip(go): use unique index instead of primary key;

* wip(go): wire ctx in extclient apis;

* wip(go): wire ctx in extclient apis;

* wip(go): remove org id check from global scoped get tenant api;

* wip(go): add scope slug to discover response;

* wip(go): wire ctx in get users;

* wip(go): set tenant id in posture check violations;

* wip(go): upsert membership instead of user;

* wip(go): pass correct len;

* wip(go): optimize (single loop);

* wip(go): unify todos;

* wip(go): add todo;

* wip(go): wire context for network get;

* wip(go): wire context;

* wip(go): wire context;

* wip(go): wire ctx when getting tenant superadmin;

* wip(go): add network creator to network admin group;

* wip(go): check for user membership on sync;

* wip(go): add methods with strict membership check;

* wip(go): remove unwanted feature flags;

* wip(go): report per-tenant usage during license validation;

* wip(go): move email setting from tenant to org;

* wip(go): pass org scope to email init;

* wip(go): remove old tenant email settings usages; use org settings to get email settings

* wip(go): use get email settings from org settings;

* wip(go): remove email settings migration;

* wip(go): migrate email settings from server to org settings;

* wip(go): assign global network admin group membership only for super-admin and admins;

* wip(go): assign global network admin group membership only for super-admin and admins;

* wip(go): add membership alias to filter;

* wip(go): skip clearing local license validation response cache;

* wip(go): extend license validation response for multi-tenancy;

* wip(go): add functions to get sole tenant and org;

* wip(go): use tenant-specific feature flags;

* wip(go): add tenant status type;

* wip(go): remove old hook comment;

* wip(go): expect ctx during license validation;

* wip(go): always create default org and tenant for ce;

* wip(go): add tenant-specific check for license validation error;

* wip(go): cache license validation response in memory;

* wip(go): add todo to handle multiple-tenant for integration pull;

* wip(go): pass ctx during license validation;

* wip(go): add todo for making changes later; run hook separately for each tenant;

* wip(go): return specific errors when license validation response not in cache;

* wip(go): use retryable.httpclient for calling license validation api;

* wip(go): process license validation;

* wip(go): license validation function;

* wip(go): migration on license validation;

* wip(go): org settings not expected;

* wip(go): remove expiry from license validation response;

* wip(go): add limits and limits enforcement to license validation response;

* wip(go): remove expiry usages;

* wip(go): add arg to clear cache;

* wip(go): add arg to clear cache;

* wip(go): skip license validation if cached resp;

* wip(go): allow org_id and tenant_id query params;

* wip(go): upsert tenant membership on sync;

* wip(go): pass ctx to delete network roles;

* wip(go): create tenant membership on org owner creation on ce;

* wip(go): add tenant orchestrator;

* wip(go): use tenant orchestrator;

* wip(go): add default settings for tenant;

* wip(go): make org owner superadmin on all tenants;

* wip(go): simplify SyncOrgAndTenants;

* wip(go): retry license validation only 3 times

* wip(go): store invalid err;

* wip(go): add function to check if limits are to be enforced on a tenant;

* wip(go): pass tenant scope if possible when checking for license validation error;

* wip(go): remove merge errors;

* wip(go): add functions for limit checks;

* wip(go): wire host limit check;

* wip(go): wire ingress limit check;

* wip(go): wire egress limit check;

* wip(go): wire client limit check;

* wip(go): wire network limit check;

* wip(go): wire user limit check;

* wip(go): use correct errors;

* wip(go): skip telemetry for msps;

* wip(go): disable telemetry on msp tenants;

* wip(go): err check;

* wip(go): uncomment log event;

* wip(go): get sso state to get scope for non-headless oauth login;

* wip(go): inline delete pending user;

* wip(go): allow both org and tenant scope for pending users table;

* wip(go): add migration for pending user table during set tenant id and rekey org/tenant id;

* wip(go): update queries to use new scope fields in pending users table;

* wip(go): allow both org and tenant scope for user invites table;

* wip(go): update queries to use new scope fields;

* wip(go): add migration for rekey-ing org/tenant id and setting tenant id;

* wip(go): inline delete user invite;

* wip(go): set scope when creating invite;

* wip(go): get user count with membership is ctx is tenant scoped;

* wip(go): add methods to upsert and delete org memberships;

* wip(go): add global valid tenant and org roles map;

* wip(go): add scope query in user invite get by email;

* wip(go): add support for upsert and delete of org and tenant membership both;

* wip(go): infer scope for user invite apis;

* wip(go): wire ctx in get user invite by email;

* wip(go): allow no scope headers to be passed in infer scope apis;

* wip(go): add validation and checks for org-scoped invite api handlers;

* wip(go): wire ctx to is pending user check;

* wip(go): infer scope for pending user apis;

* wip(go): set default role based on ctx scope;

* wip(go): move mfa to tenant membership table; move account status to membership tables;

* wip(go): migrate account status and mfa settings;

* wip(go): update account status on sync;

* wip(go): use update mfa to update mfa setting;

* wip(go): set new membership settings on user creation;

* wip(go): use membership where required;

* wip(go): wire ctx on key unlink refs;

* wip(go): use r.Context directly without reinitializing db;

* wip(go): wire ctx in acls;

* wip(go): add tenant scoped primary key constructor;

* wip(go): use tenant scoped primary key for acls, dns, extclients and tags;

* wip(go): wire ctx for get groups in network;

* wip(go): wire ctx for get groups;

* wip(go): scope non-uuid group ids by tenant id;

* wip(go): migrate non-uuid group ids to scoped ids;

* wip(go): don't expect scope id in invite validation and signup apis;

* wip(go): wire ctx for get matching user; allow org scope when querying by external idp id;

* wip(go): add mfa to org;

* wip(go): scope preauth token;

* wip(go): add fallback for older auth tokens;

* wip(go): wire ctx for preauth token creation;

* wip(go): migrate mfa settings;

* wip(go): resolve mfa settings for inherited auth;

* wip(go): update mfa for org;

* wip(go): infer scope for totp apis;

* wip(go): use ctx when passed;

* wip(go): infer scope for list user apis;

* wip(go): ensure set primary keys don't cause errors during fetch;

* wip(go): make primary key for integrations uuid and store provider in separate key;

* wip(go): migrate integration ids;

* wip(go): use provider instead of id everywhere;

* wip(go): remove gorm column;

* wip(go): wire ctx for igw egress creation;

* wip(go): wire ctx for igw egress update;

* wip(go): wire ctx for host registration;

* wip(go): wire ctx for host registration;

* wip(go): merge errors;

* wip(go): merge errors;

* wip(go): wire ctx; remove default scope usages;

* wip(go): use get with membership;

* wip(go): org scope license validation;

* wip(go): remove debug log;

* wip(go): remove scoping on list roles;

* wip(go): ignore invite creation if already exists;

* wip(go): add is msp func to check if deployment is msp deployment;

* wip(go): ensure username is email for msp users;

* wip(go): add email validated check on user table;

* wip(go): mark email validated when oauth user;

* wip(go): handle gorm error;

* wip(go): add tenant id to siem grpc schema;

* wip(go): read tenant from scoped ctx;

* wip(go): pass scoped ctx to siem grpc client;

* wip(go): remove unused publisher;

* wip(go): publish all tenant exporter updates on pull;

* wip(go): fix wg usage;

* wip(go): tenant scope the ctx when getting server info;

* wip(go): add tenant id to enrollment key token;

* wip(go): make host register tenant scope;

* wip(go): add default settings;

* wip(go): use correct checks for is msp tenant;

* wip(go): add email validation extension;

* wip(go): add pages for validation check;

* wip(go): use extension for validating user;

* wip(go): add invite type;

* wip(go): create only one validation invite per user email;

* wip(go): add validate email handler;

* wip(go): add email builder;

* wip(go): add user invite type migration logic;

* wip(go): only log email send failures;

* wip(go): add tenant id to flows table;

* wip(go): add func to check if connected to clickhouse;

* wip(go): migrate clickhouse;

* wip(go): add tenant id to mdm and edr state;

* wip(go): add delete methods for tables;

* wip(go): add tenant teardown method;

* wip(go): handle tenant deletion;

* wip(go): clean up flow logs on tenant deletion;

* wip(go): add update org membership role id method;

* wip(go): add transfer org ownership api;

* wip(go): move audit logs retention period to org settings;

* wip(go):get retention period from org settings;

* wip(go): remove old audit logs usage;

* wip(go): migrate audit logs retention period;

* wip(go): unmarshal decodes nums as float64;

* wip(go): run flow logs cleanup for all tenants in one go;

* wip(go): add audit logs retention period and smtp settings to tenant settings table for non-msp tenants;

* wip(go): resolve retention period and smtp settings correctly;

* wip(go): handle retention period and smtp settings updates and fetches correctly;

* wip(go): overlay retention period and smtp settings on server settings;;

* wip(go): upsert overlayed settings;

* wip(go): add azure tenant id to org settings;

* wip(go): tenant scope list roles;

* wip(go): add noop creds;

* wip(go): use first instead of find;

* wip(go): use correct roles when transfering ownership;

* wip(go): delete access tokens only if tenant scoped;

* wip(go): make delete user work for both org and tenant scope;

* wip(go): delete extclients only if tenant scoped;

* wip(go): check if caller is allowed to delete user;

* wip(go): infer scope for update user;

* wip(go): prevent org-owner elevation;

* wip(go): permissions check for update;

* wip(go): delete user access tokens only if tenant scope;

* wip(go): delete extclients only if tenant scope;

* wip(go): move upsert membership in upsert user;

* wip(go): set api host when constructing server config;

* wip(go): fix static errors;

* wip(go): fix test cases;

* wip(go): use correct get ns order;

* wip(go): use correct get pending host order;

* wip(go): use correct get access token order;

* wip(go): use correct get event order;

* wip(go): return error response on emqx user creation error;

* wip(go): store correct value in cache;

* wip(go): return better error response;

* wip(go): return better error response;

* wip(go): use cache when listing egress acls;

* wip(go): wire ctx in UserHasNetworkGroupAccess;

* wip(go): remove double scope wrapping;

* wip(go): add security check for list and get org;

* wip(go): ratelimit discover api;

* wip(go): expect sole org id in get and create org owner;

* wip(go): wrap create org owner in tx;
2026-08-05 21:25:02 +08:00

723 lines
23 KiB
Go

package controllers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/gravitl/netmaker/db"
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/logic"
"github.com/gravitl/netmaker/middleware"
"github.com/gravitl/netmaker/models"
"github.com/gravitl/netmaker/pro/email"
proLogic "github.com/gravitl/netmaker/pro/logic"
"github.com/gravitl/netmaker/schema"
"github.com/gravitl/netmaker/scope"
"golang.org/x/exp/slog"
)
func JITHandlers(r *mux.Router) {
r.HandleFunc("/api/v1/jit", middleware.Scope(scope.TenantScope, logic.SecurityCheck(true,
http.HandlerFunc(handleJIT)))).Methods(http.MethodPost, http.MethodGet)
r.HandleFunc("/api/v1/jit", middleware.Scope(scope.TenantScope, logic.SecurityCheck(true,
http.HandlerFunc(deleteJITGrant)))).Methods(http.MethodDelete)
r.HandleFunc("/api/v1/jit_user/networks", middleware.Scope(scope.TenantScope, logic.SecurityCheck(false,
http.HandlerFunc(getUserJITNetworks)))).Methods(http.MethodGet)
r.HandleFunc("/api/v1/jit_user/request", middleware.Scope(scope.TenantScope, logic.SecurityCheck(false,
http.HandlerFunc(requestJITAccess)))).Methods(http.MethodPost)
}
// @Summary List JIT requests for a network
// @Router /api/v1/jit [get]
// @Tags JIT
// @Security oauth
// @Produce json
// @Param network query string true "Network ID"
// @Param status query string false "Filter by status (pending, approved, denied, expired)"
// @Param page query int false "Page number"
// @Param per_page query int false "Items per page"
// @Success 200 {array} schema.JITRequest
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
//
// @Summary Handle JIT operations (enable, disable, approve, deny)
// @Router /api/v1/jit [post]
// @Tags JIT
// @Security oauth
// @Accept json
// @Produce json
// @Param network query string true "Network ID"
// @Param body body models.JITOperationRequest true "JIT operation request (user_group_ids for enable / update_jit_user_groups)"
// @Success 200 {object} models.SuccessResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func handleJIT(w http.ResponseWriter, r *http.Request) {
// Check if JIT feature is enabled
featureFlags := logic.GetFeatureFlags(r.Context())
if !featureFlags.EnableJIT {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("JIT feature is not enabled"), "forbidden"))
return
}
networkID := r.URL.Query().Get("network")
if networkID == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network is required"), "badrequest"))
return
}
username := r.Header.Get("user")
if username == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("user not found in request"), "unauthorized"))
return
}
user := &schema.User{Username: username}
err := user.GetWithMembership(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized"))
return
}
switch r.Method {
case http.MethodGet:
handleJITGet(w, r, networkID, user)
case http.MethodPost:
handleJITPost(w, r, networkID, user)
default:
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("method not allowed"), "badrequest"))
}
}
// handleJITGet - handles GET requests for JIT status/requests
func handleJITGet(w http.ResponseWriter, r *http.Request, networkID string, user *schema.User) {
statusFilter := r.URL.Query().Get("status") // "pending", "approved", "denied", "expired", or empty for all
// Parse pagination parameters (default to 0, db.SetPagination will apply defaults)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
// Apply defaults if not provided (matching db.SetPagination logic)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
ctx := db.WithContext(r.Context())
requests, total, err := proLogic.GetNetworkJITRequestsPaginated(ctx, networkID, statusFilter, page, pageSize)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
// Calculate pagination metadata
totalPages := (int(total) + pageSize - 1) / pageSize
if totalPages == 0 {
totalPages = 1
}
response := models.PaginatedResponse{
Data: requests,
Page: page,
PerPage: pageSize,
Total: int(total),
TotalPages: totalPages,
}
logic.ReturnSuccessResponseWithJson(w, r, response, "fetched JIT requests")
}
// handleJITPost - handles POST requests for JIT operations
func handleJITPost(w http.ResponseWriter, r *http.Request, networkID string, user *schema.User) {
var req models.JITOperationRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
logger.Log(0, "error decoding request body:", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
switch req.Action {
case "enable":
handleEnableJIT(w, r, networkID, user, req.UserGroupIDs)
case "disable":
handleDisableJIT(w, r, networkID, user)
case "update_jit_user_groups":
handleUpdateJITUserGroups(w, r, networkID, user, req.UserGroupIDs)
case "approve":
handleApproveRequest(w, r, networkID, user, req.RequestID, req.ExpiresAt)
case "deny":
handleDenyRequest(w, r, networkID, user, req.RequestID)
default:
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("invalid action"), "badrequest"))
}
}
// handleEnableJIT - enables JIT on a network
func handleEnableJIT(w http.ResponseWriter, r *http.Request, networkID string, user *schema.User, userGroupIDs []string) {
// Check if user is admin
if !proLogic.IsNetworkAdmin(r.Context(), user, networkID) {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("only network admins can enable JIT"), "forbidden"))
return
}
if err := proLogic.EnableJITOnNetwork(r.Context(), networkID, jitRequestToGroupIDs(userGroupIDs)); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
logic.LogEvent(r.Context(), &models.Event{
Action: schema.JitEnable,
Source: models.Subject{
ID: user.Username,
Name: user.Username,
Type: schema.UserSub,
},
TriggeredBy: user.Username,
Target: models.Subject{
ID: networkID,
Name: networkID,
Type: schema.NetworkSub,
},
NetworkID: schema.NetworkID(networkID),
Origin: schema.Dashboard,
})
logic.ReturnSuccessResponse(w, r, "JIT enabled on network")
}
// handleDisableJIT - disables JIT on a network
func handleDisableJIT(w http.ResponseWriter, r *http.Request, networkID string, user *schema.User) {
// Check if user is admin
if !proLogic.IsNetworkAdmin(r.Context(), user, networkID) {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("only network admins can disable JIT"), "forbidden"))
return
}
if err := proLogic.DisableJITOnNetwork(r.Context(), networkID); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
logic.LogEvent(r.Context(), &models.Event{
Action: schema.JitDisable,
Source: models.Subject{
ID: user.Username,
Name: user.Username,
Type: schema.UserSub,
},
TriggeredBy: user.Username,
Target: models.Subject{
ID: networkID,
Name: networkID,
Type: schema.NetworkSub,
},
NetworkID: schema.NetworkID(networkID),
Origin: schema.Dashboard,
})
logic.ReturnSuccessResponse(w, r, "JIT disabled on network")
}
// handleUpdateJITUserGroups - updates JIT user-group allowlist while JIT stays enabled
func handleUpdateJITUserGroups(w http.ResponseWriter, r *http.Request, networkID string, user *schema.User, userGroupIDs []string) {
if !proLogic.IsNetworkAdmin(r.Context(), user, networkID) {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("only network admins can update JIT user groups"), "forbidden"))
return
}
currNet := &schema.Network{Name: networkID}
err := (currNet).Get(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
// Snapshot the previous group names before we mutate the network, so the
// audit diff retains human-readable names even if any of the listed groups
// are deleted later.
oldSnapshot := newJITNetworkAuditSnapshot(r.Context(), currNet)
if err := proLogic.UpdateJITUserGroupsOnNetwork(r.Context(), networkID, jitRequestToGroupIDs(userGroupIDs)); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
updatedNet := &schema.Network{Name: networkID}
err = (updatedNet).Get(r.Context())
if err != nil {
slog.Error("failed to fetch updated network for JIT user-group audit diff", "network", networkID, "error", err)
return
}
diff := models.Diff{
Old: oldSnapshot,
New: newJITNetworkAuditSnapshot(r.Context(), updatedNet),
}
logic.LogEvent(r.Context(), &models.Event{
Action: schema.JitGroupsUpdate,
Source: models.Subject{
ID: user.Username,
Name: user.Username,
Type: schema.JITSub,
},
TriggeredBy: user.Username,
Target: models.Subject{
ID: networkID,
Name: networkID,
Type: schema.JITSub,
},
NetworkID: schema.NetworkID(networkID),
Origin: schema.Dashboard,
Diff: diff,
})
logic.ReturnSuccessResponse(w, r, "JIT user groups updated")
}
// jitUserGroupRef pins a user-group ID alongside its display name at the moment
// of an audit event, so the event remains human-readable even after the
// underlying group is deleted.
type jitUserGroupRef struct {
ID schema.UserGroupID `json:"id"`
Name string `json:"name"`
}
// jitNetworkAuditSnapshot is the payload stored in JIT audit-event diffs.
// It embeds the network so existing consumers continue to see the same
// fields (including jit_user_group_ids), and adds a parallel jit_user_groups
// list with the resolved names captured at event time.
type jitNetworkAuditSnapshot struct {
*schema.Network
JITUserGroups []jitUserGroupRef `json:"jit_user_groups"`
}
func newJITNetworkAuditSnapshot(ctx context.Context, network *schema.Network) jitNetworkAuditSnapshot {
snap := jitNetworkAuditSnapshot{Network: network}
if network == nil {
return snap
}
snap.JITUserGroups = make([]jitUserGroupRef, 0, len(network.JITUserGroupIDs))
for _, gid := range network.JITUserGroupIDs {
ref := jitUserGroupRef{ID: gid}
grp := &schema.UserGroup{ID: gid}
if err := grp.Get(ctx); err == nil {
ref.Name = grp.Name
}
snap.JITUserGroups = append(snap.JITUserGroups, ref)
}
return snap
}
func jitRequestToGroupIDs(ids []string) []schema.UserGroupID {
out := make([]schema.UserGroupID, 0, len(ids))
for _, id := range ids {
if id == "" {
continue
}
out = append(out, schema.UserGroupID(id))
}
return out
}
// handleApproveRequest - approves a JIT request
func handleApproveRequest(w http.ResponseWriter, r *http.Request, networkID string, user *schema.User, requestID string, expiresAtEpoch int64) {
// Check if user is admin
if !proLogic.IsNetworkAdmin(r.Context(), user, networkID) {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("only network admins can approve requests"), "forbidden"))
return
}
if requestID == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("request_id is required"), "badrequest"))
return
}
if expiresAtEpoch <= 0 {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("expires_at is required and must be a valid Unix epoch timestamp"), "badrequest"))
return
}
// Convert epoch to time.Time
expiresAt := time.Unix(expiresAtEpoch, 0).UTC()
now := time.Now().UTC()
// Validate that expires_at is in the future
if expiresAt.Before(now) || expiresAt.Equal(now) {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("expires_at must be in the future"), "badrequest"))
return
}
grant, req, err := proLogic.ApproveJITRequest(r.Context(), requestID, expiresAt, user.Username)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
// Send approval email to user
ctx := scope.WithContext(db.WithContext(context.Background()), scope.Level(r.Context()), scope.ID(r.Context()))
go func(ctx context.Context) {
network := &schema.Network{Name: networkID}
_ = network.Get(ctx)
if err := email.SendJITApprovalEmail(grant, req, network); err != nil {
slog.Error("failed to send approval notification", "error", err)
}
}(ctx)
logic.LogEvent(r.Context(), &models.Event{
Action: schema.JitRequestApprove,
Source: models.Subject{
ID: user.Username,
Name: user.Username,
Type: schema.UserSub,
},
TriggeredBy: user.Username,
Target: models.Subject{
ID: grant.UserID,
Name: grant.UserID,
Type: schema.UserSub,
},
NetworkID: schema.NetworkID(networkID),
Origin: schema.Dashboard,
})
logic.ReturnSuccessResponseWithJson(w, r, grant, "JIT request approved")
}
// handleDenyRequest - denies a JIT request
func handleDenyRequest(w http.ResponseWriter, r *http.Request, networkID string, user *schema.User, requestID string) {
// Check if user is admin
if !proLogic.IsNetworkAdmin(r.Context(), user, networkID) {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("only network admins can deny requests"), "forbidden"))
return
}
if requestID == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("request_id is required"), "badrequest"))
return
}
request, err := proLogic.DenyJITRequest(requestID, user.Username)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
// Send denial email to requester
ctx := scope.WithContext(db.WithContext(context.Background()), scope.Level(r.Context()), scope.ID(r.Context()))
go func(ctx context.Context) {
network := &schema.Network{Name: networkID}
_ = network.Get(ctx)
if err := email.SendJITDeniedEmail(request, network); err != nil {
slog.Error("failed to send JIT denied notification", "error", err)
}
}(ctx)
logic.LogEvent(r.Context(), &models.Event{
Action: schema.JitRequestDeny,
Source: models.Subject{
ID: user.Username,
Name: user.Username,
Type: schema.UserSub,
},
TriggeredBy: user.Username,
Target: models.Subject{
ID: request.UserID,
Name: request.UserID,
Type: schema.UserSub,
},
NetworkID: schema.NetworkID(networkID),
Origin: schema.Dashboard,
})
logic.ReturnSuccessResponse(w, r, "JIT request denied")
}
// @Summary Delete/revoke a JIT grant
// @Router /api/v1/jit [delete]
// @Tags JIT
// @Security oauth
// @Produce json
// @Param network query string true "Network ID"
// @Param grant_id query string true "Grant ID to revoke"
// @Success 200 {object} models.SuccessResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func deleteJITGrant(w http.ResponseWriter, r *http.Request) {
// Check if JIT feature is enabled
featureFlags := logic.GetFeatureFlags(r.Context())
if !featureFlags.EnableJIT {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("JIT feature is not enabled"), "forbidden"))
return
}
networkID := r.URL.Query().Get("network")
grantID := r.URL.Query().Get("grant_id")
if networkID == "" || grantID == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network and grant_id are required"), "badrequest"))
return
}
username := r.Header.Get("user")
if username == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("user not found in request"), "unauthorized"))
return
}
user := &schema.User{Username: username}
err := user.GetWithMembership(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized"))
return
}
// Check if user is admin
if !proLogic.IsNetworkAdmin(r.Context(), user, networkID) {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("only network admins can revoke grants"), "forbidden"))
return
}
grant := schema.JITGrant{ID: grantID}
if err := grant.Get(r.Context()); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if grant.NetworkID != networkID {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("grant does not belong to this network"), "badrequest"))
return
}
// Delete all grants for this user on this network (in case there are multiple)
if err := proLogic.DeactivateUserGrantsOnNetwork(r.Context(), networkID, grant.UserID); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
// Update associated request status to "expired" for all approved requests from this user
request := schema.JITRequest{
NetworkID: networkID,
UserID: grant.UserID,
}
allRequests, err := request.ListByNetwork(r.Context())
var revokedRequest *schema.JITRequest
if err == nil {
for _, req := range allRequests {
if req.UserID == grant.UserID && req.Status == "approved" {
req.Status = "expired"
req.RevokedAt = time.Now().UTC()
if err := req.Update(r.Context()); err != nil {
logger.Log(0, "failed to update request status when revoking grant:", err.Error())
// Don't fail the operation, just log
} else {
// Use the first approved request for email notification
if revokedRequest == nil {
revokedRequest = &req
}
}
}
}
}
// Send email notification to user
if revokedRequest != nil {
network := &schema.Network{Name: networkID}
err := network.Get(r.Context())
if err == nil {
if err := email.SendJITExpirationEmail(&grant, revokedRequest, network, true, user.Username); err != nil {
slog.Warn("failed to send revocation email", "grant_id", grantID, "user", revokedRequest.UserName, "error", err)
}
}
}
// Remove user's ext clients and host nodes from the network.
if err := proLogic.RemoveUserJITNetworkAccess(r.Context(), networkID, grant.UserID); err != nil {
logger.Log(0, "failed to remove network access when revoking grant:", err.Error())
}
logic.LogEvent(r.Context(), &models.Event{
Action: schema.JitGrantRevoke,
Source: models.Subject{
ID: user.Username,
Name: user.Username,
Type: schema.UserSub,
},
TriggeredBy: user.Username,
Target: models.Subject{
ID: grant.UserID,
Name: grant.UserID,
Type: schema.UserSub,
},
NetworkID: schema.NetworkID(networkID),
Origin: schema.Dashboard,
})
logic.ReturnSuccessResponse(w, r, "JIT grant revoked")
}
// @Summary Get user JIT networks status
// @Router /api/v1/jit_user/networks [get]
// @Tags JIT
// @Security oauth
// @Produce json
// @Success 200 {array} models.UserJITNetworkStatus
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func getUserJITNetworks(w http.ResponseWriter, r *http.Request) {
// Check if JIT feature is enabled
featureFlags := logic.GetFeatureFlags(r.Context())
if !featureFlags.EnableJIT {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("JIT feature is not enabled"), "forbidden"))
return
}
username := r.Header.Get("user")
if username == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("user not found in request"), "unauthorized"))
return
}
user := &schema.User{Username: username}
err := user.GetWithMembership(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized"))
return
}
// Get all networks user has access to
allNetworks, err := (&schema.Network{}).ListAll(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
// Filter networks by user role
userNetworks := logic.FilterNetworksByRole(r.Context(), allNetworks, user)
// Build response with JIT status for each network
networksWithJITStatus, err := proLogic.GetUserJITNetworksStatus(r.Context(), userNetworks, user)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
logic.ReturnSuccessResponseWithJson(w, r, networksWithJITStatus, "fetched user JIT network status")
}
// @Summary Request JIT access to a network
// @Router /api/v1/jit_user/request [post]
// @Tags JIT
// @Security oauth
// @Accept json
// @Produce json
// @Param network query string true "Network ID"
// @Param body body models.JITAccessRequest true "JIT access request"
// @Success 200 {object} schema.JITRequest
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func requestJITAccess(w http.ResponseWriter, r *http.Request) {
// Check if JIT feature is enabled
featureFlags := logic.GetFeatureFlags(r.Context())
if !featureFlags.EnableJIT {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("JIT feature is not enabled"), "forbidden"))
return
}
username := r.Header.Get("user")
if username == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("user not found in request"), "unauthorized"))
return
}
network := r.URL.Query().Get("network")
user := &schema.User{Username: username}
err := user.GetWithMembership(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized"))
return
}
var req models.JITAccessRequest
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
logger.Log(0, "error decoding request body:", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
req.NetworkID = network
// Validate required fields
if req.NetworkID == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network_id is required"), "badrequest"))
return
}
if req.Reason == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("reason is required"), "badrequest"))
return
}
// Check if user has access to the network by role
allNetworks, err := (&schema.Network{}).ListAll(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
// Filter networks by user role
userNetworks := logic.FilterNetworksByRole(r.Context(), allNetworks, user)
hasAccess := false
for _, network := range userNetworks {
if network.Name == req.NetworkID {
hasAccess = true
break
}
}
if !hasAccess {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("user does not have access to this network"), "forbidden"))
return
}
// Create the JIT request
request, err := proLogic.CreateJITRequest(r.Context(), req.NetworkID, user.Username, req.Reason)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
// Send email notifications to network admins
ctx := scope.WithContext(db.WithContext(context.Background()), scope.Level(r.Context()), scope.ID(r.Context()))
go func(ctx context.Context) {
network := &schema.Network{Name: req.NetworkID}
_ = network.Get(ctx)
if err := email.SendJITRequestEmails(ctx, request, network); err != nil {
slog.Error("failed to send JIT request notifications", "error", err)
}
}(ctx)
logic.LogEvent(r.Context(), &models.Event{
Action: schema.JitRequestCreate,
Source: models.Subject{
ID: user.Username,
Name: user.Username,
Type: schema.UserSub,
},
TriggeredBy: user.Username,
Target: models.Subject{
ID: request.ID,
Name: req.NetworkID,
Type: schema.NetworkSub,
},
NetworkID: schema.NetworkID(req.NetworkID),
Origin: schema.ClientApp,
})
logic.ReturnSuccessResponseWithJson(w, r, request, "JIT access request created")
}