diff --git a/core2/go.mod b/core2/go.mod index 1213d260de..aad9aa8886 100644 --- a/core2/go.mod +++ b/core2/go.mod @@ -50,7 +50,7 @@ require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/core2/go.sum b/core2/go.sum index d4c0592ae3..008af4749d 100644 --- a/core2/go.sum +++ b/core2/go.sum @@ -56,8 +56,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= diff --git a/core2/pkg/accounting/00_module.go b/core2/pkg/accounting/00_module.go index 0bf4440b27..b8215b4fef 100644 --- a/core2/pkg/accounting/00_module.go +++ b/core2/pkg/accounting/00_module.go @@ -43,6 +43,9 @@ func Init() { initGrantsExport() times["GrantsExport"] = t.Mark() + initPolicySubscriptions() + times["PolicySubscriptions"] = t.Mark() + coreutil.PrintStartupTimes("Accounting", times) if util.DevelopmentModeEnabled() { diff --git a/core2/pkg/accounting/accounting.go b/core2/pkg/accounting/accounting.go index bc2fdb70eb..212b7860cf 100644 --- a/core2/pkg/accounting/accounting.go +++ b/core2/pkg/accounting/accounting.go @@ -28,6 +28,9 @@ func initAccounting() { go accountingProcessTasks() accapi.RootAllocate.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[accapi.RootAllocateRequest]) (fndapi.BulkResponse[fndapi.FindByStringId], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Client IP is not allowed by project") + } var result []fndapi.FindByStringId for _, reqItem := range request.Items { id, err := RootAllocate(info.Actor, reqItem) @@ -41,6 +44,9 @@ func initAccounting() { }) accapi.UpdateAllocation.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[accapi.UpdateAllocationRequest]) (util.Empty, *util.HttpError) { + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not allowed by project") + } return UpdateAllocation(info.Actor, request.Items) }) @@ -58,6 +64,9 @@ func initAccounting() { }) accapi.WalletsBrowse.Handler(func(info rpc.RequestInfo, request accapi.WalletsBrowseRequest) (fndapi.PageV2[accapi.WalletV2], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[accapi.WalletV2]{}, util.HttpErr(http.StatusForbidden, "Client IP is not allowed by project") + } return WalletsBrowse(info.Actor, request), nil }) diff --git a/core2/pkg/accounting/grants.go b/core2/pkg/accounting/grants.go index 901d180fae..e50b3c7793 100644 --- a/core2/pkg/accounting/grants.go +++ b/core2/pkg/accounting/grants.go @@ -2056,14 +2056,23 @@ func initGrants() { if !grantGlobals.Testing.Enabled { accapi.GrantsBrowse.Handler(func(info rpc.RequestInfo, request accapi.GrantsBrowseRequest) (fndapi.PageV2[accapi.GrantApplication], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[accapi.GrantApplication]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return GrantsBrowse(info.Actor, request), nil }) accapi.GrantsRetrieve.Handler(func(info rpc.RequestInfo, request fndapi.FindByStringId) (accapi.GrantApplication, *util.HttpError) { + if sourceIPisRestricted(info) { + return accapi.GrantApplication{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return GrantsRetrieve(info.Actor, request.Id) }) accapi.GrantsSubmitRevision.Handler(func(info rpc.RequestInfo, request accapi.GrantsSubmitRevisionRequest) (fndapi.FindByStringId, *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.FindByStringId{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } id, err := GrantsSubmitRevision(info.Actor, request) if err != nil { return fndapi.FindByStringId{}, err @@ -2086,6 +2095,9 @@ func initGrants() { }) accapi.GrantsPostComment.Handler(func(info rpc.RequestInfo, request accapi.GrantsPostCommentRequest) (fndapi.FindByStringId, *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.FindByStringId{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } id, err := GrantsPostComment(info.Actor, request) if err != nil { return fndapi.FindByStringId{}, err @@ -2095,6 +2107,9 @@ func initGrants() { }) accapi.GrantsDeleteComment.Handler(func(info rpc.RequestInfo, request accapi.GrantsDeleteCommentRequest) (util.Empty, *util.HttpError) { + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return util.Empty{}, GrantsDeleteComment(info.Actor, request) }) diff --git a/core2/pkg/accounting/grants_export.go b/core2/pkg/accounting/grants_export.go index 03a0775e47..5eaa31566d 100644 --- a/core2/pkg/accounting/grants_export.go +++ b/core2/pkg/accounting/grants_export.go @@ -2,6 +2,7 @@ package accounting import ( "fmt" + "net/http" "strings" "time" @@ -14,10 +15,16 @@ import ( func initGrantsExport() { accapi.GrantsExport.Handler(func(info rpc.RequestInfo, request util.Empty) ([]accapi.GrantsExportResponse, *util.HttpError) { + if sourceIPisRestricted(info) { + return nil, util.HttpErr(http.StatusForbidden, "Client IP is not allowed by project") + } return GrantsExportBrowse(info.Actor), nil }) accapi.GrantsExportCsv.Handler(func(info rpc.RequestInfo, request util.Empty) (accapi.GrantsExportCsvResponse, *util.HttpError) { + if sourceIPisRestricted(info) { + return accapi.GrantsExportCsvResponse{}, util.HttpErr(http.StatusForbidden, "Client IP is not allowed by project") + } csv := GrantsExportBrowseToCsv(GrantsExportBrowse(info.Actor)) return accapi.GrantsExportCsvResponse{ FileName: "grants.csv", diff --git a/core2/pkg/accounting/policy_cache.go b/core2/pkg/accounting/policy_cache.go new file mode 100644 index 0000000000..0c416a3115 --- /dev/null +++ b/core2/pkg/accounting/policy_cache.go @@ -0,0 +1,107 @@ +package accounting + +import ( + "context" + "net" + "sync" + + "ucloud.dk/core/pkg/coreutil" + db "ucloud.dk/shared/pkg/database" + fndapi "ucloud.dk/shared/pkg/foundation" + "ucloud.dk/shared/pkg/log" + "ucloud.dk/shared/pkg/rpc" + "ucloud.dk/shared/pkg/util" +) + +// policyCache is a mapping of projectId -> map[schemaName] -> PolicySpecification +var policyCache struct { + Mu sync.RWMutex + PoliciesByProject map[string]map[string]*fndapi.PolicySpecification +} + +func initPolicySubscriptions() { + + policyCache.Mu.Lock() + policyCache.PoliciesByProject = make(map[string]map[string]*fndapi.PolicySpecification) + policyCache.Mu.Unlock() + + go func() { + policyUpdates := db.Listen(context.Background(), "policy_updates") + policyDeletes := db.Listen(context.Background(), "policy_deleted") + + var projectId string + var policySpecifications map[string]*fndapi.PolicySpecification + var policiesOk bool + + for { + select { + case projectId = <-policyUpdates: + db.NewTx0(func(tx *db.Transaction) { + policySpecifications, policiesOk = coreutil.PolicySpecificationsRetrieveFromDatabase(tx, projectId) + }) + case projectId = <-policyDeletes: + db.NewTx0(func(tx *db.Transaction) { + + policySpecifications, policiesOk = coreutil.PolicySpecificationsRetrieveFromDatabase(tx, projectId) + }) + } + + if policiesOk { + updatePolicyCacheForProject(projectId, policySpecifications) + } + } + + }() +} + +// policiesByProject returns mapping of [schema Name] => PolicySpecification. If no policy is cached for the project it +// will attempt to retrieve it from DB. This is also how it is populated. +func policiesByProject(projectId string) map[string]*fndapi.PolicySpecification { + projectPolicies := map[string]*fndapi.PolicySpecification{} + policyCache.Mu.Lock() + projectPolicies, ok := policyCache.PoliciesByProject[projectId] + if !ok { + log.Debug("No policies for project %v", projectId) + db.NewTx0(func(tx *db.Transaction) { + policySpecifications, policiesOk := coreutil.PolicySpecificationsRetrieveFromDatabase(tx, projectId) + if policiesOk { + policyCache.PoliciesByProject[projectId] = policySpecifications + } else { + log.Debug("No policies for project %v found in DB", projectId) + } + }) + } + policyCache.Mu.Unlock() + + return projectPolicies +} + +func updatePolicyCacheForProject(projectId string, policySpecifications map[string]*fndapi.PolicySpecification) { + policyCache.Mu.Lock() + policyCache.PoliciesByProject[projectId] = policySpecifications + policyCache.Mu.Unlock() +} + +func sourceIPisRestricted(info rpc.RequestInfo) bool { + if info.Actor.Project.Present { + sourceIpSpecs, hasSourceRestriction := policiesByProject(info.Actor.Project.String())[fndapi.RestrictSourceIPRange.String()] + if hasSourceRestriction { + isRestricted := true + for _, property := range sourceIpSpecs.Properties { + if property.Name == "allowedClientSubnets" { + allowedIps := property.Text + if allowedIps == "" { + break + } + _, subnet, _ := net.ParseCIDR(allowedIps) + ip := net.ParseIP(util.ClientIP(info.HttpRequest).String()) + if subnet.Contains(ip) { + isRestricted = false + } + } + } + return isRestricted + } + } + return false +} diff --git a/core2/pkg/accounting/provider_notifications.go b/core2/pkg/accounting/provider_notifications.go index 875a520119..59c1481139 100644 --- a/core2/pkg/accounting/provider_notifications.go +++ b/core2/pkg/accounting/provider_notifications.go @@ -23,20 +23,42 @@ import ( var providerWalletNotifications = make(chan AccWalletId, 1024*1024) var providerNotifications struct { - Mu sync.Mutex + Mu sync.Mutex + + // All of these follow providerId -> sessionId -> channel + ProjectChannelsByProvider map[string]map[string]chan *fndapi.Project WalletsByProvider map[string]map[string]chan *accapi.WalletV2 + PoliciesByProvider map[string]map[string]chan fndapi.PoliciesForProject +} + +func retrieveRelevantProviders(projectId string) map[string]util.Empty { + projectWallets := internalRetrieveWallets(time.Now(), projectId, walletFilter{RequireActive: true}) + relevantProviders := map[string]util.Empty{} + + for _, w := range projectWallets { + relevantProviders[w.PaysFor.Provider] = util.Empty{} + } + + return relevantProviders } func initProviderNotifications() { providerNotifications.ProjectChannelsByProvider = map[string]map[string]chan *fndapi.Project{} providerNotifications.WalletsByProvider = map[string]map[string]chan *accapi.WalletV2{} + providerNotifications.PoliciesByProvider = map[string]map[string]chan fndapi.PoliciesForProject{} + + policyCache.Mu.Lock() + policyCache.PoliciesByProject = make(map[string]map[string]*fndapi.PolicySpecification) + policyCache.Mu.Unlock() go func() { // NOTE(Dan): These two channels receive events from database triggers set on the relevant insert/update/delete // operations. The payload is either the project or group ID which triggered the update. projectUpdates := db.Listen(context.Background(), "project_updates") groupUpdates := db.Listen(context.Background(), "project_group_updates") + policyUpdates := db.Listen(context.Background(), "policy_updates") + policyDeletes := db.Listen(context.Background(), "policy_deleted") for { var project fndapi.Project @@ -45,6 +67,10 @@ func initProviderNotifications() { var walletId AccWalletId var walletOk bool + var policySpecifications map[string]*fndapi.PolicySpecification + var projectIdForPolices string + var policiesOk bool + select { case projectId := <-projectUpdates: db.NewTx0(func(tx *db.Transaction) { @@ -58,15 +84,22 @@ func initProviderNotifications() { case walletId = <-providerWalletNotifications: walletOk = true + + case projectId := <-policyUpdates: + db.NewTx0(func(tx *db.Transaction) { + policySpecifications, policiesOk = coreutil.PolicySpecificationsRetrieveFromDatabase(tx, projectId) + }) + projectIdForPolices = projectId + + case projectId := <-policyDeletes: + db.NewTx0(func(tx *db.Transaction) { + policySpecifications, policiesOk = coreutil.PolicySpecificationsRetrieveFromDatabase(tx, projectId) + }) + projectIdForPolices = projectId } if projectOk { - projectWallets := internalRetrieveWallets(time.Now(), project.Id, walletFilter{RequireActive: true}) - relevantProviders := map[string]util.Empty{} - - for _, w := range projectWallets { - relevantProviders[w.PaysFor.Provider] = util.Empty{} - } + relevantProviders := retrieveRelevantProviders(project.Id) var allChannels []chan *fndapi.Project @@ -109,6 +142,31 @@ func initProviderNotifications() { } } } + } else if policiesOk { + relevantProviders := retrieveRelevantProviders(projectIdForPolices) + var allChannels []chan fndapi.PoliciesForProject + + providerNotifications.Mu.Lock() + + for provider := range relevantProviders { + channels, ok := providerNotifications.PoliciesByProvider[provider] + if ok { + for _, ch := range channels { + allChannels = append(allChannels, ch) + } + } + } + + providerNotifications.Mu.Unlock() + + updatePolicyCacheForProject(projectIdForPolices, policySpecifications) + + for _, ch := range allChannels { + select { + case ch <- fndapi.PoliciesForProject{ProjectId: projectIdForPolices, PoliciesByName: policySpecifications}: + case <-time.After(200 * time.Millisecond): + } + } } } }() @@ -175,6 +233,12 @@ func providerNotificationHandleClient(conn *ws.Conn) { RefToCategory map[int]accapi.ProductCategory } + policies struct { + Counter int + ProjectIdToRef map[string]int + RefToProjectId map[int]string + } + ctx context.Context cancel context.CancelFunc ) @@ -188,6 +252,9 @@ func providerNotificationHandleClient(conn *ws.Conn) { productCategories.IdToRef = map[accapi.ProductCategoryIdV2]int{} productCategories.RefToCategory = map[int]accapi.ProductCategory{} + policies.ProjectIdToRef = map[string]int{} + policies.RefToProjectId = map[int]string{} + ctx, cancel = context.WithCancel(context.Background()) // Subscription @@ -195,6 +262,7 @@ func providerNotificationHandleClient(conn *ws.Conn) { sessionId := util.RandomTokenNoTs(32) projectUpdates := make(chan *fndapi.Project, 128) walletUpdates := make(chan *accapi.WalletV2, 128) + policyUpdates := make(chan fndapi.PoliciesForProject, 128) { providerNotifications.Mu.Lock() @@ -213,6 +281,13 @@ func providerNotificationHandleClient(conn *ws.Conn) { } pmap[sessionId] = projectUpdates + polmap, ok := providerNotifications.PoliciesByProvider[providerId] + if !ok { + polmap = map[string]chan fndapi.PoliciesForProject{} + providerNotifications.PoliciesByProvider[providerId] = polmap + } + polmap[sessionId] = policyUpdates + providerNotifications.Mu.Unlock() } @@ -222,6 +297,7 @@ func providerNotificationHandleClient(conn *ws.Conn) { providerNotifications.Mu.Lock() delete(providerNotifications.WalletsByProvider[providerId], sessionId) delete(providerNotifications.ProjectChannelsByProvider[providerId], sessionId) + delete(providerNotifications.PoliciesByProvider[providerId], sessionId) providerNotifications.Mu.Unlock() }() @@ -344,6 +420,7 @@ func providerNotificationHandleClient(conn *ws.Conn) { projectsToSend := map[int]util.Empty{} usersToSend := map[int]util.Empty{} + policiesToSend := map[int]fndapi.PoliciesForProject{} var walletsToSend []*accapi.WalletV2 categoriesToSend := map[int]util.Empty{} @@ -365,6 +442,25 @@ func providerNotificationHandleClient(conn *ws.Conn) { return ref } + appendPolicies := func(policiesForProject fndapi.PoliciesForProject, forced bool) int { + projectID := policiesForProject.ProjectId + ref, ok := policies.ProjectIdToRef[projectID] + if !ok { + ref, ok = policies.Counter, true + policies.ProjectIdToRef[projectID] = ref + policies.RefToProjectId[ref] = projectID + policiesToSend[ref] = policiesForProject + + policies.Counter++ + } else if forced { + policies.ProjectIdToRef[projectID] = ref + policies.RefToProjectId[ref] = projectID + policiesToSend[ref] = policiesForProject + } + + return ref + } + appendProjectById := func(projectId string) int { ref, ok := projects.ProjectIdToRef[projectId] if !ok { @@ -439,6 +535,22 @@ func providerNotificationHandleClient(conn *ws.Conn) { out.WriteString(string(projectJson)) } + for policyRef, _ := range policiesToSend { + project := policies.RefToProjectId[policyRef] + + policyCache.Mu.Lock() + currentPolices := policyCache.PoliciesByProject[project] + projectPolicies := fndapi.PoliciesForProject{ + project, + currentPolices, + } + currentProjectPoliciesJson, _ := json.Marshal(projectPolicies) + policyCache.Mu.Unlock() + out.WriteU8(opPolicyChange) + out.WriteU32(uint32(policyRef)) + out.WriteString(string(currentProjectPoliciesJson)) + } + for categoryRef, _ := range categoriesToSend { category := productCategories.RefToCategory[categoryRef] categoryJson, _ := json.Marshal(category) @@ -485,6 +597,7 @@ func providerNotificationHandleClient(conn *ws.Conn) { categoriesToSend = map[int]util.Empty{} usersToSend = map[int]util.Empty{} walletsToSend = nil + policiesToSend = nil if err != nil { cancel() @@ -508,6 +621,11 @@ func providerNotificationHandleClient(conn *ws.Conn) { if ok { appendProject(project, true) } + + case projectPolicies, ok := <-policyUpdates: + if ok { + appendPolicies(projectPolicies, true) + } } flush() @@ -515,10 +633,11 @@ func providerNotificationHandleClient(conn *ws.Conn) { } const ( - opAuth = 0 - opWallet = 1 - opProject = 2 - opCategory = 3 - opUser = 4 - opReplayUser = 5 + opAuth = 0 + opWallet = 1 + opProject = 2 + opCategory = 3 + opUser = 4 + opReplayUser = 5 + opPolicyChange = 6 ) diff --git a/core2/pkg/coreutil/00_module.go b/core2/pkg/coreutil/00_module.go index 8a1c03c986..742edc122e 100644 --- a/core2/pkg/coreutil/00_module.go +++ b/core2/pkg/coreutil/00_module.go @@ -168,6 +168,42 @@ func ProjectRetrieveFromDatabaseViaGroupId(tx *db.Transaction, groupId string) ( } } +func PolicySpecificationsRetrieveFromDatabase(tx *db.Transaction, projectId string) (map[string]*fndapi.PolicySpecification, bool) { + + rows := db.Select[struct { + ProjectId string + PolicyName string + PolicyProperties string + }]( + tx, + ` + select project_id, policy_name, policy_properties + from project.policies + where project_id = :id + `, + db.Params{ + "id": projectId, + }, + ) + var policies = make(map[string]*fndapi.PolicySpecification) + for _, row := range rows { + properties := []fndapi.PolicyPropertyValue{} + err := json.Unmarshal([]byte(row.PolicyProperties), &properties) + if err != nil { + log.Debug("Error unmarshalling policy properties on update") + return nil, false + } + specification := fndapi.PolicySpecification{ + Schema: row.PolicyName, + Project: rpc.ProjectId(row.ProjectId), + Properties: properties, + } + + policies[specification.Schema] = &specification + } + return policies, true +} + func ProjectsListUpdatedAfter(timestamp time.Time) []rpc.ProjectId { return db.NewTx(func(tx *db.Transaction) []rpc.ProjectId { rows := db.Select[struct{ Id string }]( diff --git a/core2/pkg/foundation/00_module.go b/core2/pkg/foundation/00_module.go index eb404223e4..0b4f915cf1 100644 --- a/core2/pkg/foundation/00_module.go +++ b/core2/pkg/foundation/00_module.go @@ -47,5 +47,8 @@ func Init() { initAuthOidc() times["Oidc"] = t.Mark() + initPolicies() + times["Policies"] = t.Mark() + coreutil.PrintStartupTimes("Foundation", times) } diff --git a/core2/pkg/foundation/policies.go b/core2/pkg/foundation/policies.go new file mode 100644 index 0000000000..42836d6814 --- /dev/null +++ b/core2/pkg/foundation/policies.go @@ -0,0 +1,257 @@ +package foundation + +import ( + "encoding/json" + "net/http" + "sync" + + "golang.org/x/exp/maps" + "gopkg.in/yaml.v3" + "ucloud.dk/shared/pkg/cfgutil" + db "ucloud.dk/shared/pkg/database" + fndapi "ucloud.dk/shared/pkg/foundation" + "ucloud.dk/shared/pkg/log" + "ucloud.dk/shared/pkg/rpc" + "ucloud.dk/shared/pkg/util" +) + +var projectPolicies struct { + Mu sync.RWMutex + PoliciesByProject map[string]*AssociatedPolicies +} + +type AssociatedPolicies struct { + EnabledPolices map[string]fndapi.PolicySpecification +} + +var policySchemas map[string]fndapi.PolicySchema + +func initPolicies() { + policyPopulateSchemaCache() + loadProjectPoliciesFromDB() + + fndapi.PoliciesRetrieve.Handler(func(info rpc.RequestInfo, request fndapi.RetrievePoliciesRequest) (map[string]fndapi.Policy, *util.HttpError) { + return policiesRetrieve(info.Actor, request) + }) + + fndapi.PoliciesUpdate.Handler(func(info rpc.RequestInfo, request fndapi.PoliciesUpdateRequest) (util.Empty, *util.HttpError) { + return policiesUpdate(info.Actor, request) + }) +} + +func policyPopulateSchemaCache() { + policies := pullProjectPolicies() + policySchemas = make(map[string]fndapi.PolicySchema, len(policies)) + for _, policy := range policies { + var document yaml.Node + success := true + + err := yaml.Unmarshal(policy.Bytes, &document) + if err != nil { + log.Fatal("Error loading policy document ", policy.PolicyName, ": ", err) + } + + var policySchema fndapi.PolicySchema + + cfgutil.Decode("", &document, &policySchema, &success) + + if !success { + log.Fatal("Error decoding policy document ", policy.PolicyName, ": ", err) + } + policySchemas[policySchema.Name] = policySchema + } +} + +func loadProjectPoliciesFromDB() { + projectPolicies.Mu.Lock() + projectPolicies.PoliciesByProject = make(map[string]*AssociatedPolicies) + projectPolicies.Mu.Unlock() + + db.NewTx0(func(tx *db.Transaction) { + rows := db.Select[struct { + ProjectId string + PolicyName string + PolicyProperties string + }]( + tx, + ` + select project_id, policy_name, policy_properties + from project.policies + order by project_id + `, + db.Params{}, + ) + + projectPolicies.Mu.Lock() + + for _, row := range rows { + projectId := row.ProjectId + policies, ok := projectPolicies.PoliciesByProject[projectId] + if !ok { + policies = &AssociatedPolicies{EnabledPolices: map[string]fndapi.PolicySpecification{}} + } + properties := []fndapi.PolicyPropertyValue{} + err := json.Unmarshal([]byte(row.PolicyProperties), &properties) + if err != nil { + log.Fatal("Error loading policy document %v : %v", row.PolicyProperties, err) + } + specification := fndapi.PolicySpecification{ + Schema: row.PolicyName, + Project: rpc.ProjectId(projectId), + Properties: properties, + } + + log.Debug("Loading policy %v \n", specification) + + policies.EnabledPolices[row.PolicyName] = specification + projectPolicies.PoliciesByProject[projectId] = policies + } + + projectPolicies.Mu.Unlock() + }) +} + +func policiesRetrieve(actor rpc.Actor, request fndapi.RetrievePoliciesRequest) (map[string]fndapi.Policy, *util.HttpError) { + projectId := request.ProjectId + if actor.Role != rpc.RoleProvider { + if !actor.Project.Present { + return nil, util.HttpErr(http.StatusBadRequest, "Polices only applicable to projects") + } + if !actor.Membership[actor.Project.Value].Equals(rpc.ProjectRolePI) { + return nil, util.HttpErr(http.StatusForbidden, "Only data managers may list the policies") + } + projectId = actor.Project.String() + } + + result := make(map[string]fndapi.Policy, len(policySchemas)) + + projectPolicies.Mu.RLock() + _, ok := projectPolicies.PoliciesByProject[projectId] + if !ok { + projectPolicies.PoliciesByProject[projectId] = &AssociatedPolicies{} + } + policies := maps.Clone(projectPolicies.PoliciesByProject[projectId].EnabledPolices) + projectPolicies.Mu.RUnlock() + for name, schema := range policySchemas { + + specification, ok := policies[name] + if !ok { + specification = fndapi.PolicySpecification{} + } + result[name] = fndapi.Policy{ + Schema: schema, + Specification: specification, + } + } + return result, nil +} + +type SimplePolicyProperty struct { + PropertyType fndapi.PolicyPropertyType `yaml:"property_type"` + PropertyValue any `yaml:"property_value"` +} + +func policiesUpdate(actor rpc.Actor, request fndapi.PoliciesUpdateRequest) (util.Empty, *util.HttpError) { + if !actor.Project.Present { + return util.Empty{}, util.HttpErr(http.StatusBadRequest, "Polices only applicable to projects") + } + if !actor.Membership[actor.Project.Value].Equals(rpc.ProjectRoleDataManager) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Only data managers may update the policies") + } + + filteredUpdates := map[string]map[string]fndapi.PolicySpecification{} + for _, specification := range request.UpdatedPolicies { + projectId := string(specification.Project) + filteredUpdates[projectId][specification.Schema] = specification + } + + db.NewTx0(func(tx *db.Transaction) { + b := db.BatchNew(tx) + for projectId, updates := range filteredUpdates { + for _, specification := range updates { + _, ok := policySchemas[specification.Schema] + //When trying to update a schema that does not exist, we just skip it + if !ok { + log.Debug("Unknown Schema ", specification.Schema) + continue + } + + isEnabled := false + for _, property := range specification.Properties { + if property.Name == "enabled" { + isEnabled = property.Bool + } + } + + //If the policy is not enabled we need to delete it form the DB. + //Else we need to insert or update already existing project policy + if !isEnabled { + db.BatchExec( + b, + ` + delete from project.policies + where project_id = :project_id and policy_name = :policy_name + `, + db.Params{ + "project_id": projectId, + "policy_name": specification.Schema, + }, + ) + } else { + properties, err := json.Marshal(specification.Properties) + if err != nil { + log.Debug("Error marshalling policy document ", specification.Schema, " ", specification.Properties) + continue + } + db.BatchExec( + b, + ` + insert into project.policies (policy_name, policy_property, project_id) + values (:policy_name, :policy_properties, :project_id) + on conflict (policy_name, project_id) do + update set policy_property = excluded.policy_property, + `, + db.Params{ + "policy_name": specification.Schema, + "policy_properties": properties, + "project_id": projectId, + }, + ) + } + } + } + db.BatchSend(b) + + //Updating cache + projectPolicies.Mu.Lock() + for _, updates := range filteredUpdates { + for _, specification := range updates { + isEnabled := false + for _, property := range specification.Properties { + if property.Name == "enabled" { + isEnabled = property.Bool + } + } + projectId := string(specification.Project) + if !isEnabled { + policies, ok := projectPolicies.PoliciesByProject[projectId] + //If no policies are enabled for the project then just skip the deletion + if !ok { + continue + } + policies.EnabledPolices[specification.Schema] = fndapi.PolicySpecification{} + } else { + policies, ok := projectPolicies.PoliciesByProject[projectId] + if !ok { + policies = &AssociatedPolicies{EnabledPolices: map[string]fndapi.PolicySpecification{}} + projectPolicies.PoliciesByProject[projectId] = policies + } + policies.EnabledPolices[specification.Schema] = specification + } + } + } + projectPolicies.Mu.Unlock() + }) + + return util.Empty{}, nil +} diff --git a/core2/pkg/foundation/policies/restrict_applications.yaml b/core2/pkg/foundation/policies/restrict_applications.yaml new file mode 100644 index 0000000000..dd616b5714 --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_applications.yaml @@ -0,0 +1,28 @@ +name: "RestrictApplications" +title: "Restrict applications available for use" + +description: > + Restricts which applications are available for use in the project. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, the project will only be able to run applications that are listed along this policy. + If disabled the all apps in the app catalog is available (within the regular limitations of products needed). + - name: "applications" + type: "TextList" + title: "Applications" + description: > + List of applications which the project should be restricted to + use. This refers to the canonical application name. This name + can be found in the UI by copying the canonical name of a concrete + _flavor_. + + An empty list indicates that there are no apps available within the project. + Any additions to the list will cause the project to be able to run these applications. + + This configuration option does not affect provider registered + applications. This option cannot be used to control Syncthing or + other integrated applications. \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_cut_and_paste.yaml b/core2/pkg/foundation/policies/restrict_cut_and_paste.yaml new file mode 100644 index 0000000000..e1fe98a989 --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_cut_and_paste.yaml @@ -0,0 +1,21 @@ +name: "RestrictCutAndPaste" +title: "Disable copy & paste in interactive applications" + +description: > + This option will turn off copy & paste in interactive applications. This is + implemented by forcing access to all interactive applications to be done + through a remote desktop. This option is insufficient if Internet access + is allowed. Copy & paste will still be possible internally in the + application, if supported, but it will not be available on the client + machine. + + This feature will automatically disable the "Open terminal" feature on all + jobs. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, copy & paste will be turned off. If not enabled, + copy & paste will be available if the application supports it. \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_downloads.yaml b/core2/pkg/foundation/policies/restrict_downloads.yaml new file mode 100644 index 0000000000..9cdf9fa15d --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_downloads.yaml @@ -0,0 +1,15 @@ +name: "RestrictDownloads" +title: "Restrict downloads" + +description: > + Controls whether users are allowed to download data from the portal + to their local machine. Note that this policy may be ineffective if + the provider allows direct access to the data. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, downloads to the client machine are blocked. + If disabled, downloads are allowed. \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_integrated_applications.yaml b/core2/pkg/foundation/policies/restrict_integrated_applications.yaml new file mode 100644 index 0000000000..be27ea2512 --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_integrated_applications.yaml @@ -0,0 +1,25 @@ +name: "RestrictIntegratedApplications" +title: "Restrict integrated applications (e.g. Syncthing and terminal)" + +description: > + Restricts applications from running which are registered by the provider. + This includes applications such as Syncthing and the terminal, which can + be opened from the file browser. + +configuration: + - name: "enabled" + type: Bool + title: "Enabled" + description: > + If this option is checked, then restrictions will apply and only + applications found on the allow-list can be run. If it is not + checked then all integrated applications can be run and the + allow-list is ignored. + - name: "allowList" + type: EnumSet + title: "Allow-list" + description: > + List of applications which are explicitly allowed. + options: + - "syncthing" + - "terminal" \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_internet_access.yaml b/core2/pkg/foundation/policies/restrict_internet_access.yaml new file mode 100644 index 0000000000..74f464ecb4 --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_internet_access.yaml @@ -0,0 +1,18 @@ +name: "RestrictInternetAccess" +title: "Restrict Internet access" +description: > + Limits outbound Internet connectivity from workloads belonging to the project. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, restriction of internet access will be activated and a limited predefined + subnet range is enforced. + - name: "allowedSubnets" + type: "Subnet" + title: "Allowed subnets" + description: > + Only the specified destination subnets are reachable. An empty string means + no Internet access is allowed. \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_organizations_members.yaml b/core2/pkg/foundation/policies/restrict_organizations_members.yaml new file mode 100644 index 0000000000..fd52477be1 --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_organizations_members.yaml @@ -0,0 +1,18 @@ +name: "RestrictOrganizationMembers" +title: "Restrict members to organization" + +description: > + Ensures that all project members belong to the same organization. This does not remove existing project members that + are not a part of the organization. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, invitations to users outside the specified organization are rejected. + - name: "organizations" + type: "TextList" + title: "Organizations" + description: > + The list of organizations which are allowed in the project. \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_provider_file_transfers.yaml b/core2/pkg/foundation/policies/restrict_provider_file_transfers.yaml new file mode 100644 index 0000000000..937a9d98a9 --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_provider_file_transfers.yaml @@ -0,0 +1,21 @@ +name: "RestrictProviderTransfers" +title: "Restrict file transfers between providers" + +description: > + Controls which providers may participate in data transfers for this project. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, users will only be able to transfer files between specific providers. + If disabled, users can move files between any provider they have access to. + - name: "allowedProviders" + type: "Providers" + title: "Allowed providers" + description: > + Only the listed providers may be used as source or destination for file + transfers. This option does not guarantee that data transfer between + selected providers is possible, that is, providers may overrule this + with their own transfer policies. \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_public_ips.yaml b/core2/pkg/foundation/policies/restrict_public_ips.yaml new file mode 100644 index 0000000000..cc5343e535 --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_public_ips.yaml @@ -0,0 +1,12 @@ +name: "RestrictPublicIPs" +title: "Restrict public IPs" +description: > + Controls whether workloads in the project may be assigned public IP addresses. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, public IP creation and use is disabled. Existing public + IPs are _not_ deleted. \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_public_links.yaml b/core2/pkg/foundation/policies/restrict_public_links.yaml new file mode 100644 index 0000000000..21bd890fbb --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_public_links.yaml @@ -0,0 +1,12 @@ +name: "RestrictPublicLinks" +title: "Restrict public links" +description: > + Controls whether public (unauthenticated) links to project data are allowed. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, creation and use of public links is disabled. Existing + public links are _not_ deleted. \ No newline at end of file diff --git a/core2/pkg/foundation/policies/restrict_source_ip_range.yaml b/core2/pkg/foundation/policies/restrict_source_ip_range.yaml new file mode 100644 index 0000000000..80f0815eea --- /dev/null +++ b/core2/pkg/foundation/policies/restrict_source_ip_range.yaml @@ -0,0 +1,20 @@ +name: "RestrictSourceIPRange" +title: "Restrict client source IP range" + +description: > + Limits which client IP ranges are allowed to access the project. + +configuration: + - name: "enabled" + type: "Bool" + title: "Enabled" + description: > + If enabled, users will only be able to access the project from specified subnets. + If disabled, users can access the project from any IP in the world. + + - name: "allowedClientSubnets" + type: "Subnet" + title: "Allowed client subnets" + description: > + Only clients originating from these subnets are permitted. An empty + string blocks all external access. \ No newline at end of file diff --git a/core2/pkg/foundation/policy_templates.go b/core2/pkg/foundation/policy_templates.go new file mode 100644 index 0000000000..277e9365ed --- /dev/null +++ b/core2/pkg/foundation/policy_templates.go @@ -0,0 +1,76 @@ +package foundation + +import _ "embed" + +//go:embed policies/restrict_applications.yaml +var restrictApplications []byte + +//go:embed policies/restrict_cut_and_paste.yaml +var restrictCutAndPast []byte + +//go:embed policies/restrict_downloads.yaml +var restrictDownloads []byte + +//go:embed policies/restrict_integrated_applications.yaml +var restrictIntegratedApplications []byte + +//go:embed policies/restrict_internet_access.yaml +var restrictInternetAccess []byte + +//go:embed policies/restrict_organizations_members.yaml +var restrictOrganizationsMembers []byte + +//go:embed policies/restrict_provider_file_transfers.yaml +var restrictProviderTransfers []byte + +//go:embed policies/restrict_public_ips.yaml +var restrictPublicIPs []byte + +//go:embed policies/restrict_public_links.yaml +var restrictPublicLinks []byte + +//go:embed policies/restrict_source_ip_range.yaml +var restrictSourceIpRange []byte + +type LoadedPolicy struct { + PolicyName string + Bytes []byte +} + +func pullProjectPolicies() []LoadedPolicy { + policies := []LoadedPolicy{ + { + PolicyName: "restrictApplications", + Bytes: restrictApplications, + }, + { + PolicyName: "restrictCutAndPast", + Bytes: restrictCutAndPast, + }, { + PolicyName: "restrictDownloads", + Bytes: restrictDownloads, + }, { + PolicyName: "restrictIntegratedApplications", + Bytes: restrictIntegratedApplications, + }, { + PolicyName: "restrictInternetAccess", + Bytes: restrictInternetAccess, + }, { + PolicyName: "restrictOrganizationsMembers", + Bytes: restrictOrganizationsMembers, + }, { + PolicyName: "restrictProviderTransfers", + Bytes: restrictProviderTransfers, + }, { + PolicyName: "restrictPublicIPs", + Bytes: restrictPublicIPs, + }, { + PolicyName: "restrictPublicLinks", + Bytes: restrictPublicLinks, + }, { + PolicyName: "restrictSourceIpRange", + Bytes: restrictSourceIpRange, + }, + } + return policies +} diff --git a/core2/pkg/foundation/projects.go b/core2/pkg/foundation/projects.go index 3cbb028931..4f89c3e1a8 100644 --- a/core2/pkg/foundation/projects.go +++ b/core2/pkg/foundation/projects.go @@ -1439,6 +1439,28 @@ func ProjectAcceptInviteLink(actor rpc.Actor, token string) (fndapi.ProjectInvit return fndapi.ProjectInviteLinkInfo{}, err } + projectPolicies.Mu.Lock() + projectRestrictions, hasRestrictions := projectPolicies.PoliciesByProject[linkInfo.Project.Id] + projectPolicies.Mu.Unlock() + + if hasRestrictions { + policySpec, restrictingMemberOrgs := projectRestrictions.EnabledPolices[fndapi.RestrictOrganizationMembers.String()] + if restrictingMemberOrgs { + var allowedOrgs []string + for _, property := range policySpec.Properties { + if property.Name == "organizations" { + allowedOrgs = property.TextElements + } + break + } + for _, org := range allowedOrgs { + if org == actor.OrgId { + return fndapi.ProjectInviteLinkInfo{}, util.HttpErr(http.StatusForbidden, "Project does not allow your organization.") + } + } + } + } + if linkInfo.IsMember { return fndapi.ProjectInviteLinkInfo{}, util.HttpErr(http.StatusBadRequest, "You are already a member of this project!") } @@ -1589,6 +1611,58 @@ func ProjectCreateInvite(actor rpc.Actor, recipient string) *util.HttpError { return util.HttpErr(http.StatusBadRequest, "you cannot invite this user to the project") } + restrictingProjectMemberOrganization := false + var allowedOrgs []string + if actor.Project.Present { + projectPolicies.Mu.Lock() + policies, found := projectPolicies.PoliciesByProject[actor.Project.String()] + if found { + policySpecification, restricted := policies.EnabledPolices[fndapi.RestrictOrganizationMembers.String()] + if restricted { + restrictingProjectMemberOrganization = true + for _, property := range policySpecification.Properties { + if property.Name == "organizations" { + allowedOrgs = property.TextElements + break + } + } + } + } + projectPolicies.Mu.Unlock() + } + + if restrictingProjectMemberOrganization { + canBeInvited := false + for _, org := range allowedOrgs { + if org == recipientActor.OrgId { + canBeInvited = true + } + } + if !canBeInvited { + errorMessage := "Cannot invite user." + if len(allowedOrgs) == 0 { + errorMessage = errorMessage + " Invites disabled by project manager" + } else { + errorMessage = errorMessage + " Only users from" + for i, org := range allowedOrgs { + if i == len(allowedOrgs)-1 { + errorMessage = errorMessage + fmt.Sprintf(" %s", org) + } else { + errorMessage = errorMessage + fmt.Sprintf(" %s,", org) + } + } + errorMessage = errorMessage + " allowed to be invited." + } + + //remove invite if already sent + _, found := info.InvitesSent[recipientActor.Username] + if found { + delete(info.InvitesSent, recipientActor.Username) + } + + return util.HttpErr(http.StatusForbidden, errorMessage) + } + } info.Mu.Lock() alreadyAMember := false for _, member := range info.Project.Status.Members { diff --git a/core2/pkg/migrations/00_module.go b/core2/pkg/migrations/00_module.go index 4f95718ffe..aee1c0f08d 100644 --- a/core2/pkg/migrations/00_module.go +++ b/core2/pkg/migrations/00_module.go @@ -27,6 +27,7 @@ func Init() { db.AddMigration(authV3()) db.AddMigration(grantV1()) db.AddMigration(projectsV4()) + db.AddMigration(policiesV1()) db.AddMigration(jobSettingsV1()) db.AddMigration(grantV2()) db.AddMigration(apiTokensV2()) diff --git a/core2/pkg/migrations/policies.go b/core2/pkg/migrations/policies.go new file mode 100644 index 0000000000..14c3513244 --- /dev/null +++ b/core2/pkg/migrations/policies.go @@ -0,0 +1,57 @@ +package migrations + +import db "ucloud.dk/shared/pkg/database" + +func policiesV1() db.MigrationScript { + return db.MigrationScript{ + Id: "policiesV1", + Execute: func(tx *db.Transaction) { + statements := []string{ + ` + create table if not exists project.policies ( + policy_name text not null, + policy_properties jsonb not null, + project_id text not null references project.projects(id) on delete cascade, + primary key (project_id, policy_name) + ) + `, + ` + create or replace function project.notify_policy_change() + returns trigger as $$ + begin + perform pg_notify('policy_updates', new.project_id::text); + return new; + end; + $$ language plpgsql; + `, + ` + create trigger policy_update_trigger + after insert or update + on project.policies + for each row + execute function project.notify_policy_change(); + `, + ` + create or replace function project.notify_policy_deleted() + returns trigger as $$ + begin + perform pg_notify('policy_deleted', old.project_id::text); + return old; + end; + $$ language plpgsql; + `, + ` + create trigger policy_delete_trigger + after delete + on project.policies + for each row + execute function project.notify_policy_deleted(); + `, + } + + for _, statement := range statements { + db.Exec(tx, statement, db.Params{}) + } + }, + } +} diff --git a/core2/pkg/orchestrator/00_module.go b/core2/pkg/orchestrator/00_module.go index 58f5a2e98c..ab95923bf2 100644 --- a/core2/pkg/orchestrator/00_module.go +++ b/core2/pkg/orchestrator/00_module.go @@ -38,6 +38,9 @@ func Init() { initApiTokens() times["ApiTokens"] = t.Mark() + initPolicySubscriptions() + times["PolicySubscriptions"] = t.Mark() + // Storage //================================================================================================================== diff --git a/core2/pkg/orchestrator/api_tokens.go b/core2/pkg/orchestrator/api_tokens.go index 9442151749..bc6c186030 100644 --- a/core2/pkg/orchestrator/api_tokens.go +++ b/core2/pkg/orchestrator/api_tokens.go @@ -31,10 +31,16 @@ func initApiTokens() { ) orcapi.ApiTokenCreate.Handler(func(info rpc.RequestInfo, request orcapi.ApiTokenSpecification) (orcapi.ApiToken, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.ApiToken{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ApiTokenCreate(info.Actor, request) }) orcapi.ApiTokenBrowse.Handler(func(info rpc.RequestInfo, request orcapi.ApiTokenBrowseRequest) (fndapi.PageV2[orcapi.ApiToken], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.ApiToken]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ApiTokenBrowse(info.Actor, request) }) @@ -43,6 +49,9 @@ func initApiTokens() { }) orcapi.ApiTokenRevoke.Handler(func(info rpc.RequestInfo, request fndapi.FindByStringId) (util.Empty, *util.HttpError) { + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return util.Empty{}, ApiTokenRevoke(info.Actor, ResourceParseId(request.Id)) }) diff --git a/core2/pkg/orchestrator/drive.go b/core2/pkg/orchestrator/drive.go index c13297857d..27390d4bc0 100644 --- a/core2/pkg/orchestrator/drive.go +++ b/core2/pkg/orchestrator/drive.go @@ -27,14 +27,23 @@ func initDrives() { ) orcapi.DrivesBrowse.Handler(func(info rpc.RequestInfo, request orcapi.DrivesBrowseRequest) (fndapi.PageV2[orcapi.Drive], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.Drive]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return DriveBrowse(info.Actor, request), nil }) orcapi.DrivesRetrieve.Handler(func(info rpc.RequestInfo, request orcapi.DrivesRetrieveRequest) (orcapi.Drive, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.Drive{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ResourceRetrieve[orcapi.Drive](info.Actor, driveType, ResourceParseId(request.Id), request.ResourceFlags) }) orcapi.DrivesCreate.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.DriveSpecification]) (fndapi.BulkResponse[fndapi.FindByStringId], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } created, err := DriveCreateBulk(info.Actor, request) if err != nil { return fndapi.BulkResponse[fndapi.FindByStringId]{}, err @@ -49,6 +58,9 @@ func initDrives() { }) orcapi.DrivesRename.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.DriveRenameRequest]) (util.Empty, *util.HttpError) { + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } for _, reqItem := range request.Items { err := DriveRename(info.Actor, reqItem.Id, reqItem.NewTitle) if err != nil { @@ -62,6 +74,10 @@ func initDrives() { orcapi.DrivesSearch.Handler(func(info rpc.RequestInfo, request orcapi.DrivesSearchRequest) (fndapi.PageV2[orcapi.Drive], *util.HttpError) { // TODO Sorting through the items would be ideal + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.Drive]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + items := ResourceBrowse[orcapi.Drive](info.Actor, driveType, request.Next, request.ItemsPerPage, request.ResourceFlags, func(item orcapi.Drive) bool { return strings.Contains(strings.ToLower(item.Specification.Title), strings.ToLower(request.Query)) }, nil) @@ -74,6 +90,10 @@ func initDrives() { }) orcapi.DrivesUpdateAcl.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.UpdatedAcl]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + var responses []util.Empty for _, item := range request.Items { err := ResourceUpdateAcl(info.Actor, driveType, item) @@ -90,6 +110,9 @@ func initDrives() { }) orcapi.DrivesDelete.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return DriveDelete(info.Actor, request) }) diff --git a/core2/pkg/orchestrator/files.go b/core2/pkg/orchestrator/files.go index e399c654e1..9cad543c92 100644 --- a/core2/pkg/orchestrator/files.go +++ b/core2/pkg/orchestrator/files.go @@ -21,51 +21,87 @@ import ( func initFiles() { orcapi.FilesBrowse.Handler(func(info rpc.RequestInfo, request orcapi.FilesBrowseRequest) (fndapi.PageV2[orcapi.UFile], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.UFile]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesBrowse(info.Actor, request) }) orcapi.FilesRetrieve.Handler(func(info rpc.RequestInfo, request orcapi.FilesRetrieveRequest) (orcapi.UFile, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.UFile{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesRetrieve(info.Actor, request) }) orcapi.FilesMove.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.FilesSourceAndDestination]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesMove(info.Actor, request) }) orcapi.FilesCopy.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.FilesSourceAndDestination]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesCopy(info.Actor, request) }) orcapi.FilesCreateUpload.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.FilesCreateUploadRequest]) (fndapi.BulkResponse[orcapi.FilesCreateUploadResponse], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[orcapi.FilesCreateUploadResponse]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesCreateUpload(info.Actor, request) }) orcapi.FilesCreateDownload.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[orcapi.FilesCreateDownloadResponse], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[orcapi.FilesCreateDownloadResponse]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesCreateDownload(info.Actor, request) }) orcapi.FilesCreateFolder.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.FilesCreateFolderRequest]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesCreateFolder(info.Actor, request) }) orcapi.FilesDelete.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesDelete(info.Actor, request) }) orcapi.FilesTrash.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesMoveToTrash(info.Actor, request) }) orcapi.FilesEmptyTrash.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return FilesEmptyTrash(info.Actor, request) }) orcapi.FilesStreamingSearch.Handler(func(info rpc.RequestInfo, request util.Empty) (util.Empty, *util.HttpError) { + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } FilesStreamingSearch(info.WebSocket) return util.Empty{}, nil }) orcapi.FilesTransfer.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.FilesTransferRequest]) (util.Empty, *util.HttpError) { + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } for _, reqItem := range request.Items { err := FilesTransfer(info.Actor, reqItem) if err != nil { @@ -380,6 +416,13 @@ func FilesCreateDownload( actor rpc.Actor, request fndapi.BulkRequest[fndapi.FindByStringId], ) (fndapi.BulkResponse[orcapi.FilesCreateDownloadResponse], *util.HttpError) { + if actor.Project.Present { + _, isRestricted := policiesByProject(string(actor.Project.Value))[fndapi.RestrictDownloads.String()] + if isRestricted { + return fndapi.BulkResponse[orcapi.FilesCreateDownloadResponse]{}, util.HttpErr(http.StatusForbidden, "This project does not allow downloads") + } + } + var result fndapi.BulkResponse[orcapi.FilesCreateDownloadResponse] var paths []string for _, reqItem := range request.Items { @@ -891,6 +934,34 @@ func FilesTransfer(actor rpc.Actor, request orcapi.FilesTransferRequest) *util.H return util.MergeHttpErr(err1, err2) } + if actor.Project.Present { + var allowedProviders []string + polices := policiesByProject(actor.Project.String()) + policySpecification, isRestricted := polices[fndapi.RestrictProviderTransfers.String()] + if isRestricted { + for _, property := range policySpecification.Properties { + if property.Name == "allowedProviders" { + allowedProviders = property.Providers + break + } + } + if len(allowedProviders) == 0 { + return util.HttpErr(http.StatusForbidden, "Project does not allow transfers between providers") + } else { + for _, provider := range allowedProviders { + if provider == sourceDrive.Specification.Product.Provider { + errorMsg := fmt.Sprintf("Project does not allow transfers from %v", provider) + return util.HttpErr(http.StatusForbidden, errorMsg) + } + if provider == destDrive.Specification.Product.Provider { + errorMsg := fmt.Sprintf("Project does not allow transfers to %v", provider) + return util.HttpErr(http.StatusForbidden, errorMsg) + } + } + } + } + } + if featureSupported(driveType, destDrive.Specification.Product, driveOpsReadOnly) { return util.HttpErr(http.StatusForbidden, "cannot transfer between these paths (destination is read-only)") } diff --git a/core2/pkg/orchestrator/ingress.go b/core2/pkg/orchestrator/ingress.go index 4a44059837..48216ace02 100644 --- a/core2/pkg/orchestrator/ingress.go +++ b/core2/pkg/orchestrator/ingress.go @@ -39,10 +39,17 @@ func initIngresses() { ) orcapi.IngressesBrowse.Handler(func(info rpc.RequestInfo, request orcapi.IngressesBrowseRequest) (fndapi.PageV2[orcapi.Ingress], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.Ingress]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + return IngressBrowse(info.Actor, request), nil }) orcapi.IngressesControlBrowse.Handler(func(info rpc.RequestInfo, request orcapi.IngressesControlBrowseRequest) (fndapi.PageV2[orcapi.Ingress], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.Ingress]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ResourceBrowse[orcapi.Ingress]( info.Actor, ingressType, @@ -57,6 +64,15 @@ func initIngresses() { }) orcapi.IngressesCreate.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.IngressSpecification]) (fndapi.BulkResponse[fndapi.FindByStringId], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + if info.Actor.Project.Present { + _, restricted := policiesByProject(string(info.Actor.Project.Value))[fndapi.RestrictPublicLinks.String()] + if restricted { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Project does not allow creation of public links") + } + } created, err := IngressCreate(info.Actor, request) if err != nil { return fndapi.BulkResponse[fndapi.FindByStringId]{}, err @@ -71,10 +87,16 @@ func initIngresses() { }) orcapi.IngressesDelete.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return IngressDelete(info.Actor, request) }) orcapi.IngressesSearch.Handler(func(info rpc.RequestInfo, request orcapi.IngressesSearchRequest) (fndapi.PageV2[orcapi.Ingress], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.Ingress]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ResourceBrowse[orcapi.Ingress]( info.Actor, ingressType, @@ -93,14 +115,23 @@ func initIngresses() { }) orcapi.IngressesRetrieve.Handler(func(info rpc.RequestInfo, request orcapi.IngressesRetrieveRequest) (orcapi.Ingress, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.Ingress{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ResourceRetrieve[orcapi.Ingress](info.Actor, ingressType, ResourceParseId(request.Id), request.ResourceFlags) }) orcapi.IngressesControlRetrieve.Handler(func(info rpc.RequestInfo, request orcapi.IngressesControlRetrieveRequest) (orcapi.Ingress, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.Ingress{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ResourceRetrieve[orcapi.Ingress](info.Actor, ingressType, ResourceParseId(request.Id), request.ResourceFlags) }) orcapi.IngressesUpdateAcl.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.UpdatedAcl]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } for _, item := range request.Items { err := ResourceUpdateAcl(info.Actor, ingressType, item) if err != nil { diff --git a/core2/pkg/orchestrator/job.go b/core2/pkg/orchestrator/job.go index 1917ce2ac2..f841d5b06e 100644 --- a/core2/pkg/orchestrator/job.go +++ b/core2/pkg/orchestrator/job.go @@ -58,6 +58,10 @@ func initJobs() { go jobNotificationsLoopSendPending() orcapi.JobsCreate.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.JobSpecification]) (fndapi.BulkResponse[fndapi.FindByStringId], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + created, err := JobCreate(info.Actor, request) if err != nil { return fndapi.BulkResponse[fndapi.FindByStringId]{}, err @@ -211,6 +215,10 @@ func initJobs() { }) orcapi.JobsBrowse.Handler(func(info rpc.RequestInfo, request orcapi.JobsBrowseRequest) (fndapi.PageV2[orcapi.Job], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.Job]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + return JobsBrowse(info.Actor, request.Next, request.ItemsPerPage, request.JobFlags) }) @@ -219,6 +227,9 @@ func initJobs() { }) orcapi.JobsRetrieve.Handler(func(info rpc.RequestInfo, request orcapi.JobsRetrieveRequest) (orcapi.Job, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.Job{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return JobsRetrieve(info.Actor, request.Id, request.JobFlags) }) @@ -231,6 +242,9 @@ func initJobs() { }) orcapi.JobsSearch.Handler(func(info rpc.RequestInfo, request orcapi.JobsSearchRequest) (fndapi.PageV2[orcapi.Job], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.Job]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return JobsSearch(info.Actor, request.Query, request.Next, request.ItemsPerPage, request.JobFlags) }) @@ -324,6 +338,9 @@ func initJobs() { }) orcapi.JobsUpdateAcl.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.UpdatedAcl]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } var responses []util.Empty for _, item := range request.Items { err := ResourceUpdateAcl(info.Actor, jobType, item) @@ -336,10 +353,16 @@ func initJobs() { }) orcapi.JobsExtend.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.JobsExtendRequestItem]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return JobsExtendBulk(info.Actor, request) }) orcapi.JobsTerminate.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return JobsTerminateBulk(info.Actor, request) }) @@ -439,6 +462,10 @@ func initJobs() { }) orcapi.JobsOpenInteractiveSession.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.JobsOpenInteractiveSessionRequestItem]) (fndapi.BulkResponse[orcapi.OpenSessionWithProvider], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[orcapi.OpenSessionWithProvider]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + updatesByProvider := map[string][]orcapi.JobsProviderOpenInteractiveSessionRequestItem{} indicesByProvider := map[string][]int{} @@ -584,6 +611,31 @@ func initJobs() { }) orcapi.JobsOpenTerminalInFolder.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.JobsOpenTerminalInFolderRequestItem]) (fndapi.BulkResponse[orcapi.OpenSessionWithProvider], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[orcapi.OpenSessionWithProvider]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + + if info.Actor.Project.Present { + policies := policiesByProject(info.Actor.Project.String()) + specifications, ok := policies[fndapi.RestrictIntegratedApplications.String()] + if ok { + for _, property := range specifications.Properties { + if property.Name == "allowList" { + restricted := true + for _, element := range property.TextElements { + if element == "terminal" { + restricted = false + break + } + } + if restricted { + return fndapi.BulkResponse[orcapi.OpenSessionWithProvider]{}, util.HttpErr(http.StatusForbidden, "Project does not allow users to use the integrated terminal") + } + } + } + } + } + updatesByProvider := map[string][]orcapi.JobsOpenTerminalInFolderRequestItem{} indicesByProvider := map[string][]int{} @@ -748,10 +800,16 @@ func initJobs() { }) orcapi.JobSettingsRetrieve.Handler(func(info rpc.RequestInfo, request util.Empty) (orcapi.JobSettings, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.JobSettings{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return JobSettingsRetrieve(info.Actor), nil }) orcapi.JobSettingsUpdate.Handler(func(info rpc.RequestInfo, request orcapi.JobSettings) (util.Empty, *util.HttpError) { + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } err := JobSettingsUpdate(info.Actor, request) if err != nil { return util.Empty{}, err @@ -1331,6 +1389,35 @@ func jobsValidateForSubmission(actor rpc.Actor, spec *orcapi.JobSpecification) * return util.HttpErr(http.StatusBadRequest, "unknown application requested") } + if actor.Project.Present { + var allowedApps []string + polices := policiesByProject(actor.Project.String()) + specification, restricted := polices[fndapi.RestrictApplications.String()] + if restricted { + for _, property := range specification.Properties { + if property.Name == "applications" { + allowedApps = property.TextElements + break + } + } + allowed := false + if len(allowedApps) == 0 { + return util.HttpErr(http.StatusForbidden, "Application is not allowed to run in this project context.") + } else { + for _, allowedApp := range allowedApps { + println(allowedApp) + if allowedApp == app.Metadata.Name { + allowed = true + break + } + } + } + if !allowed { + return util.HttpErr(http.StatusForbidden, "Application is not allowed to run in this project context.") + } + } + } + support, ok := SupportByProduct[orcapi.JobSupport](jobType, spec.Product) if !ok { return util.HttpErr(http.StatusBadRequest, "bad machine type requested") diff --git a/core2/pkg/orchestrator/license.go b/core2/pkg/orchestrator/license.go index a13b575627..aec6583e3f 100644 --- a/core2/pkg/orchestrator/license.go +++ b/core2/pkg/orchestrator/license.go @@ -26,6 +26,9 @@ func initLicenses() { ) orcapi.LicensesBrowse.Handler(func(info rpc.RequestInfo, request orcapi.LicensesBrowseRequest) (fndapi.PageV2[orcapi.License], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.License]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return LicenseBrowse(info.Actor, request), nil }) @@ -44,6 +47,10 @@ func initLicenses() { }) orcapi.LicensesCreate.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.LicenseSpecification]) (fndapi.BulkResponse[fndapi.FindByStringId], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + created, err := LicenseCreate(info.Actor, request) if err != nil { return fndapi.BulkResponse[fndapi.FindByStringId]{}, err @@ -58,6 +65,10 @@ func initLicenses() { }) orcapi.LicensesDelete.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + return LicenseDelete(info.Actor, request) }) @@ -76,6 +87,10 @@ func initLicenses() { }) orcapi.LicensesRetrieve.Handler(func(info rpc.RequestInfo, request orcapi.LicensesRetrieveRequest) (orcapi.License, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.License{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + return ResourceRetrieve[orcapi.License](info.Actor, licenseType, ResourceParseId(request.Id), request.ResourceFlags) }) @@ -84,6 +99,10 @@ func initLicenses() { }) orcapi.LicensesUpdateAcl.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.UpdatedAcl]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + for _, item := range request.Items { err := ResourceUpdateAcl(info.Actor, licenseType, item) if err != nil { diff --git a/core2/pkg/orchestrator/policy_cache.go b/core2/pkg/orchestrator/policy_cache.go new file mode 100644 index 0000000000..13684c2efd --- /dev/null +++ b/core2/pkg/orchestrator/policy_cache.go @@ -0,0 +1,109 @@ +package orchestrator + +import ( + "context" + "net" + "sync" + + "ucloud.dk/core/pkg/coreutil" + db "ucloud.dk/shared/pkg/database" + fndapi "ucloud.dk/shared/pkg/foundation" + "ucloud.dk/shared/pkg/log" + "ucloud.dk/shared/pkg/rpc" + "ucloud.dk/shared/pkg/util" +) + +// policyCache is a mapping of projectId -> map[schemaName] -> PolicySpecification +var policyCache struct { + Mu sync.RWMutex + PoliciesByProject map[string]map[string]*fndapi.PolicySpecification +} + +func initPolicySubscriptions() { + + policyCache.Mu.Lock() + policyCache.PoliciesByProject = make(map[string]map[string]*fndapi.PolicySpecification) + policyCache.Mu.Unlock() + + go func() { + policyUpdates := db.Listen(context.Background(), "policy_updates") + policyDeletes := db.Listen(context.Background(), "policy_deleted") + + var projectId string + var policySpecifications map[string]*fndapi.PolicySpecification + var policiesOk bool + + for { + select { + case projectId = <-policyUpdates: + + db.NewTx0(func(tx *db.Transaction) { + policySpecifications, policiesOk = coreutil.PolicySpecificationsRetrieveFromDatabase(tx, projectId) + }) + case projectId = <-policyDeletes: + + db.NewTx0(func(tx *db.Transaction) { + policySpecifications, policiesOk = coreutil.PolicySpecificationsRetrieveFromDatabase(tx, projectId) + }) + } + + if policiesOk { + updatePolicyCacheForProject(projectId, policySpecifications) + } + } + + }() +} + +// policiesByProject returns mapping of [schema Name] => PolicySpecification. If no policy is cached for the project it +// will attempt to retrieve it from DB. This is also how it is populated. +func policiesByProject(projectId string) map[string]*fndapi.PolicySpecification { + projectPolicies := map[string]*fndapi.PolicySpecification{} + policyCache.Mu.Lock() + projectPolicies, ok := policyCache.PoliciesByProject[projectId] + if !ok { + log.Debug("No policies for project %v", projectId) + db.NewTx0(func(tx *db.Transaction) { + policySpecifications, policiesOk := coreutil.PolicySpecificationsRetrieveFromDatabase(tx, projectId) + if policiesOk { + policyCache.PoliciesByProject[projectId] = policySpecifications + projectPolicies = policySpecifications + } else { + log.Debug("No policies for project %v found in DB", projectId) + } + }) + } + policyCache.Mu.Unlock() + + return projectPolicies +} + +func updatePolicyCacheForProject(projectId string, policySpecifications map[string]*fndapi.PolicySpecification) { + policyCache.Mu.Lock() + policyCache.PoliciesByProject[projectId] = policySpecifications + policyCache.Mu.Unlock() +} + +func sourceIPisRestricted(info rpc.RequestInfo) bool { + if info.Actor.Project.Present { + sourceIpSpecs, hasSourceRestriction := policiesByProject(info.Actor.Project.String())[fndapi.RestrictSourceIPRange.String()] + if hasSourceRestriction { + isRestricted := true + for _, property := range sourceIpSpecs.Properties { + if property.Name == "allowedClientSubnets" { + allowedIps := property.Text + if allowedIps == "" { + break + } + _, subnet, _ := net.ParseCIDR(allowedIps) + ip := net.ParseIP(util.ClientIP(info.HttpRequest).String()) + if subnet.Contains(ip) { + isRestricted = false + } + } + } + return isRestricted + } + } + return false +} diff --git a/core2/pkg/orchestrator/public_ip.go b/core2/pkg/orchestrator/public_ip.go index e5a359f0fb..d836fce207 100644 --- a/core2/pkg/orchestrator/public_ip.go +++ b/core2/pkg/orchestrator/public_ip.go @@ -28,6 +28,9 @@ func initPublicIps() { ) orcapi.PublicIpsBrowse.Handler(func(info rpc.RequestInfo, request orcapi.PublicIpsBrowseRequest) (fndapi.PageV2[orcapi.PublicIp], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.PublicIp]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return PublicIpBrowse(info.Actor, request), nil }) @@ -46,10 +49,22 @@ func initPublicIps() { }) orcapi.PublicIpsDelete.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return PublicIpDelete(info.Actor, request) }) orcapi.PublicIpsCreate.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.PublicIPSpecification]) (fndapi.BulkResponse[fndapi.FindByStringId], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + if info.Actor.Project.Present { + _, restricted := policiesByProject(info.Actor.Project.String())[fndapi.RestrictPublicIPs.String()] + if restricted { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Project does not allow public IPs.") + } + } created, err := PublicIpCreate(info.Actor, request) if err != nil { return fndapi.BulkResponse[fndapi.FindByStringId]{}, err @@ -81,14 +96,23 @@ func initPublicIps() { }) orcapi.PublicIpsRetrieve.Handler(func(info rpc.RequestInfo, request orcapi.PublicIpsRetrieveRequest) (orcapi.PublicIp, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.PublicIp{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ResourceRetrieve[orcapi.PublicIp](info.Actor, publicIpType, ResourceParseId(request.Id), request.ResourceFlags) }) orcapi.PublicIpsControlRetrieve.Handler(func(info rpc.RequestInfo, request orcapi.PublicIpsControlRetrieveRequest) (orcapi.PublicIp, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.PublicIp{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } return ResourceRetrieve[orcapi.PublicIp](info.Actor, publicIpType, ResourceParseId(request.Id), request.ResourceFlags) }) orcapi.PublicIpsUpdateAcl.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.UpdatedAcl]) (fndapi.BulkResponse[util.Empty], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[util.Empty]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } for _, item := range request.Items { err := ResourceUpdateAcl(info.Actor, publicIpType, item) if err != nil { diff --git a/core2/pkg/orchestrator/ssh.go b/core2/pkg/orchestrator/ssh.go index 2435134b60..214a9b250b 100644 --- a/core2/pkg/orchestrator/ssh.go +++ b/core2/pkg/orchestrator/ssh.go @@ -19,6 +19,9 @@ import ( func initSsh() { orcapi.SshCreate.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[orcapi.SshKeySpecification]) (fndapi.BulkResponse[fndapi.FindByStringId], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.BulkResponse[fndapi.FindByStringId]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } result, err := SshKeyCreate(info.Actor, request.Items) if err != nil { return fndapi.BulkResponse[fndapi.FindByStringId]{}, err @@ -28,21 +31,33 @@ func initSsh() { }) orcapi.SshRetrieve.Handler(func(info rpc.RequestInfo, request fndapi.FindByStringId) (orcapi.SshKey, *util.HttpError) { + if sourceIPisRestricted(info) { + return orcapi.SshKey{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } result, err := SshKeyRetrieve(info.Actor, request.Id) return result, err }) orcapi.SshBrowse.Handler(func(info rpc.RequestInfo, request orcapi.SshKeysBrowseRequest) (fndapi.PageV2[orcapi.SshKey], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.SshKey]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } result := SshKeyBrowse(info.Actor, request) return result, nil }) orcapi.SshDelete.Handler(func(info rpc.RequestInfo, request fndapi.BulkRequest[fndapi.FindByStringId]) (util.Empty, *util.HttpError) { + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } err := SshKeyDelete(info.Actor, request.Items) return util.Empty{}, err }) orcapi.JobsControlBrowseSshKeys.Handler(func(info rpc.RequestInfo, request orcapi.JobsControlBrowseSshKeysRequest) (fndapi.PageV2[orcapi.SshKey], *util.HttpError) { + if sourceIPisRestricted(info) { + return fndapi.PageV2[orcapi.SshKey]{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } keys, err := SshKeyRetrieveByJob(info.Actor, request.JobId, request.FilterOwner) if err != nil { return fndapi.PageV2[orcapi.SshKey]{}, err diff --git a/core2/pkg/orchestrator/syncthing.go b/core2/pkg/orchestrator/syncthing.go index 59eb7280fb..0d756a7306 100644 --- a/core2/pkg/orchestrator/syncthing.go +++ b/core2/pkg/orchestrator/syncthing.go @@ -1,11 +1,35 @@ package orchestrator import ( + "net/http" + + "ucloud.dk/shared/pkg/foundation" orcapi "ucloud.dk/shared/pkg/orchestrators" "ucloud.dk/shared/pkg/rpc" "ucloud.dk/shared/pkg/util" ) +func syncthingIsRestricted(actor rpc.Actor) bool { + if actor.Project.Present { + policies, hasRestriction := policiesByProject(actor.Project.String())[foundation.RestrictIntegratedApplications.String()] + if hasRestriction { + isRestricted := true + for _, property := range policies.Properties { + if property.Name == "allowList" { + for _, element := range property.TextElements { + if element == "syncthing" { + isRestricted = false + break + } + } + } + } + return isRestricted + } + } + return false +} + func initSyncthing() { // !! NOTE(Dan): The comment below is potentially outdated and has simply been moved from the old Core. !! // ----------------------------------------------------------------------------------------------------------------- @@ -54,7 +78,25 @@ func initSyncthing() { orcapi.SyncthingUpdateConfiguration.Handler(func(info rpc.RequestInfo, request orcapi.IAppUpdateConfigurationRequest[orcapi.SyncthingConfig]) (util.Empty, *util.HttpError) { // NOTE(Dan): This used to do permission checks in the Core, but this is no longer required since the provider // will do this instead. - + for _, folder := range request.Config.Folders { + driveID, found := orcapi.DriveIdFromUCloudPath(folder.UCloudPath) + if found { + dInfo, err := ResourceRetrieve[orcapi.Drive](info.Actor, driveType, ResourceParseId(driveID), orcapi.ResourceFlags{}) + if err != nil { + return util.Empty{}, err + } + if dInfo.Owner.Project.Present { + actorWithProject := info.Actor + actorWithProject.Project.Set(rpc.ProjectId(dInfo.Owner.Project.Value)) + if syncthingIsRestricted(actorWithProject) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Project does not allow users to use Syncthing") + } + if sourceIPisRestricted(info) { + return util.Empty{}, util.HttpErr(http.StatusForbidden, "Client IP is not accepted by project") + } + } + } + } _, err := InvokeProvider( request.Provider, orcapi.SyncthingProviderUpdateConfiguration, diff --git a/frontend-web/webclient/app/Applications/Jobs/View.tsx b/frontend-web/webclient/app/Applications/Jobs/View.tsx index 378a45fa1c..731d8c6a24 100644 --- a/frontend-web/webclient/app/Applications/Jobs/View.tsx +++ b/frontend-web/webclient/app/Applications/Jobs/View.tsx @@ -51,7 +51,7 @@ import {useScrollToBottom} from "@/ui-components/ScrollToBottom"; import {ExternalStoreBase} from "@/Utilities/ReduxUtilities"; import {appendToXterm, useXTerm} from "./XTermLib"; import {SidebarTabId} from "@/ui-components/SidebarComponents"; -import {WebSession} from "./Web"; +import {VncSession, WebSession} from "./Web"; import {RichSelect, RichSelectChildComponent} from "@/ui-components/RichSelect"; import {useDidUnmount} from "@/Utilities/ReactUtilities"; import * as JobViz from "@/Applications/Jobs/JobViz" @@ -268,18 +268,18 @@ function useJobUpdates(job: Job | undefined, callback: (entry: JobsFollowRespons const conn = WSFactory.open( "/jobs", { - init: async conn => { - await conn.subscribe({ - call: "jobs.follow", - payload: {id: job.id}, - handler: message => { - const streamEntry = message.payload as JobsFollowResponse; - callback(streamEntry); - } - }); - conn.close(); - }, - }); + init: async conn => { + await conn.subscribe({ + call: "jobs.follow", + payload: {id: job.id}, + handler: message => { + const streamEntry = message.payload as JobsFollowResponse; + callback(streamEntry); + } + }); + conn.close(); + }, + }); return () => { conn.close(); @@ -299,8 +299,8 @@ function getBackend(job?: Job): string { return job?.status.resolvedApplication?.invocation.tool.tool?.description.backend ?? ""; } -export function View(props: {id?: string; embedded?: boolean;}): React.ReactNode { - const id = props.id ?? useParams<{id: string}>().id!; +export function View(props: { id?: string; embedded?: boolean; }): React.ReactNode { + const id = props.id ?? useParams<{ id: string }>().id!; // Note: This might not match the real app name const location = useLocation(); @@ -504,7 +504,7 @@ export function View(props: {id?: string; embedded?: boolean;}): React.ReactNode const transitionRefThree = useRef(null); if (jobFetcher.error !== undefined) { - return An error occurred} />; + return An error occurred}/>; } if (isVirtualMachine && job && status && hasFeature(Feature.NEW_VM_UI)) { @@ -517,7 +517,7 @@ export function View(props: {id?: string; embedded?: boolean;}): React.ReactNode updatesState={jobUpdateState} />; if (props.embedded) return vm; - return ; + return ; } const main = ( @@ -526,8 +526,8 @@ export function View(props: {id?: string; embedded?: boolean;}): React.ReactNode
+ type={"APPLICATION"} + size={"var(--logoSize)"}/>
@@ -544,16 +544,16 @@ export function View(props: {id?: string; embedded?: boolean;}): React.ReactNode >
-
+
- +
- +
@@ -571,9 +571,10 @@ export function View(props: {id?: string; embedded?: boolean;}): React.ReactNode >
-
+
- +
@@ -596,22 +597,22 @@ export function View(props: {id?: string; embedded?: boolean;}): React.ReactNode >
-
+
- +
- +
)} - {status && isJobStateTerminal(status.state) && job ? : null} + {status && isJobStateTerminal(status.state) && job ? : null}
); if (props.embedded) return main; - return ; + return ; } const CompletedContent: React.FunctionComponent<{ @@ -630,7 +631,7 @@ const CompletedContent: React.FunctionComponent<{ ID: {shortUUID(job.id)} Reservation:{" "} - + {" "}/{" "} {job.specification.product.id}{" "} (x{job.specification.replicas}) @@ -643,7 +644,7 @@ const CompletedContent: React.FunctionComponent<{ - + @@ -663,14 +664,14 @@ const Content = injectStyle("content", k => ` } `); -function PublicLinkEntry({id}: {id: string}): React.ReactNode { +function PublicLinkEntry({id}: { id: string }): React.ReactNode { const [publicLink] = useCloudAPI(PublicLinkApi.retrieve({id}), null); - if (!id.startsWith("fake-") && publicLink.data == null) return
+ if (!id.startsWith("fake-") && publicLink.data == null) return
let domain: string; if (id.startsWith("fake")) { domain = "https://fake-public-link.example.com"; } else if (publicLink.data == null) { - return
  • ; + return
  • ; } else { domain = publicLink.data.specification.domain; } @@ -679,7 +680,7 @@ function PublicLinkEntry({id}: {id: string}): React.ReactNode { return
  • {domain}
  • ; } -const InQueueText: React.FunctionComponent<{job: Job, state: JobState}> = ({job, state}) => { +const InQueueText: React.FunctionComponent<{ job: Job, state: JobState }> = ({job, state}) => { const [utilization, setUtilization] = useCloudAPI( {noop: true}, null @@ -701,10 +702,10 @@ const InQueueText: React.FunctionComponent<{job: Job, state: JobState}> = ({job, {state === "IN_QUEUE" ? <> {job.specification.application.name === "unknown" ? <> - {job.specification.name ? <>Starting {job.specification.name} : <>Job is starting} - {" "} - (ID: {shortUUID(job.id)}) - : + {job.specification.name ? <>Starting {job.specification.name} : <>Job is starting} + {" "} + (ID: {shortUUID(job.id)}) + : job.specification.name ? (<> Starting {job.status.resolvedApplication?.metadata?.title ?? job.specification.application.name} {job.specification.application.version} @@ -739,7 +740,7 @@ const InQueueText: React.FunctionComponent<{job: Job, state: JobState}> = ({job, } } - + ; }; @@ -777,8 +778,8 @@ const Busy: React.FunctionComponent<{ We are currently preparing your job. This step might take a few minutes. } - - + + ; }; @@ -811,10 +812,10 @@ const RunningText: React.FunctionComponent<{ {" "} (ID: {job.id}) - -
    + +
    - + ; }; @@ -867,7 +868,7 @@ const RunningInfoWrapper = injectStyle("running-info-wrapper", k => ` } `); -function AltButtonGroup(props: React.PropsWithChildren<{minButtonWidth: string} & MarginProps>) { +function AltButtonGroup(props: React.PropsWithChildren<{ minButtonWidth: string } & MarginProps>) { return
    Job expiry: {dateToString(expiresAt ?? timestampUnixMs())} - Time remaining: + Time remaining: } - + {(!expiresAt || !supportsExtension) && !localStorage.getItem("useFakeState") ? null : <> Extend allocation (hours): @@ -1209,7 +1236,7 @@ const RunningContent: React.FunctionComponent<{ {!supportsSuspend ? null : suspended ? : - {peers.map(it => - - - - {it.jobId} - {" "} - - - - - - {it.hostname} - - - )} + {peers.map(it => + + + + {it.jobId} + {" "} + + + + + + {it.hostname} + + + )} @@ -1284,7 +1311,7 @@ const RunningContent: React.FunctionComponent<{ This job is publicly available through:
      - {ingresses.map(ingress => )} + {ingresses.map(ingress => )}
    } @@ -1294,7 +1321,7 @@ const RunningContent: React.FunctionComponent<{ - + @@ -1304,7 +1331,7 @@ const RunningContent: React.FunctionComponent<{ {Array(job.specification.replicas).fill(0).map((_, i) => - + )} @@ -1362,7 +1389,7 @@ const StandardPanelBody: React.FunctionComponent{children}
    ; }; -function TimeLeft({expiresAt}: {expiresAt: number}) { +function TimeLeft({expiresAt}: { expiresAt: number }) { const calculateTimeLeft = useCallback((expiresAt: number | undefined) => { if (!expiresAt) return {hours: 0, minutes: 0, seconds: 0}; @@ -1487,7 +1514,7 @@ const RunningJobRank: React.FunctionComponent<{ return
    -
    +
    }; @@ -1509,7 +1536,7 @@ function jobStateToText(state: JobState) { const UNKNOWN_APP_NAME = "unknown"; -const CompletedText: React.FunctionComponent<{job: Job, state: JobState}> = ({job, state}) => { +const CompletedText: React.FunctionComponent<{ job: Job, state: JobState }> = ({job, state}) => { const app = job.specification.application; const isUnknownApp = app.name === UNKNOWN_APP_NAME; return @@ -1530,7 +1557,7 @@ const CompletedText: React.FunctionComponent<{job: Job, state: JobState}> = ({jo } {" "}(ID: {shortUUID(job.id)}) - + {isUnknownApp || isSyncthingApp(job) ? null : @@ -1539,7 +1566,7 @@ const CompletedText: React.FunctionComponent<{job: Job, state: JobState}> = ({jo ; }; -function OutputFiles({job}: React.PropsWithChildren<{job: Job}>): React.ReactNode { +function OutputFiles({job}: React.PropsWithChildren<{ job: Job }>): React.ReactNode { const pathRef = React.useRef(job.output?.outputFolder ?? ""); if (!pathRef.current) { console.warn("No output folder found. Showing nothing."); @@ -1605,7 +1632,7 @@ const InterfaceLinkRow: RichSelectChildComponent = ({ alignItems={"center"} p={8} > - + {element.target ?? element.defaultName ?? "Open interface"} {!element.showNode ? null : @@ -1620,7 +1647,7 @@ const InterfaceLinkRow: RichSelectChildComponent = ({ const InterfaceLinkSelectedRow: RichSelectChildComponent = () => { return
    - +
    ; } @@ -1633,7 +1660,7 @@ const TerminalLinkRow: RichSelectChildComponent = ({el alignItems={"center"} p={8} > - + Node {element.rank + 1} ; @@ -1641,12 +1668,12 @@ const TerminalLinkRow: RichSelectChildComponent = ({el const TerminalLinkSelectedRow: RichSelectChildComponent = () => { return
    - +
    ; } -type SearchableInterfaceTarget = (InterfaceTarget & {searchString: string; showNode: boolean;}) -type SearchableTerminalTarget = (TerminalTarget & {searchString: string;}) +type SearchableInterfaceTarget = (InterfaceTarget & { searchString: string; showNode: boolean; }) +type SearchableTerminalTarget = (TerminalTarget & { searchString: string; }) const RunningButtonGroup: React.FunctionComponent<{ job: Job; @@ -1707,7 +1734,7 @@ const RunningButtonGroup: React.FunctionComponent<{