├── .cloudignore
├── .dockerignore
├── .git-cc.yaml
├── .github
└── workflows
│ └── go.yml
├── .gitignore
├── .golangci.yml
├── .semgrepignore
├── CHANGELOG.md
├── LICENSE.txt
├── README.md
├── buf.gen.yaml
├── buf.lock
├── buf.yaml
├── cloudbuild-ui.yaml
├── cloudbuild.yaml
├── docker-compose.dev.yaml
├── docker-compose.ui.yaml
├── docker-compose.yaml
├── docker
├── Dockerfile.buf
├── cypress
│ ├── cypress.config.ts
│ └── tsconfig.json
└── envoyproxy
│ └── envoy.yaml
├── docs
├── FINDINGS.md
├── RISK_SCORE.md
├── pics
│ ├── architecture.svg
│ ├── modron.svg
│ ├── risk_score_matrix.png
│ └── severities.png
└── screenshots
│ ├── home.jpg
│ ├── nagatha-1.jpg
│ ├── nagatha-2.jpg
│ ├── observations.jpg
│ ├── risk-score.jpg
│ ├── single-observation.jpg
│ └── stats.jpg
├── env.example
├── go.mod
├── go.sum
├── images
└── scan_process.png
├── otel
└── config
│ └── config.yaml
├── src
├── .gcloudignore
├── .gitignore
├── Dockerfile
├── Dockerfile.e2e
├── README.md
├── acl
│ ├── fakeacl
│ │ └── checkerFake.go
│ └── gcpacl
│ │ ├── cache.go
│ │ ├── cache_test.go
│ │ ├── checker.go
│ │ ├── checker_real_test.go
│ │ └── checker_test.go
├── collector
│ ├── collector.go
│ ├── gcpcollector
│ │ ├── api_fake.go
│ │ ├── api_gcp.go
│ │ ├── api_key.go
│ │ ├── bucket.go
│ │ ├── cloudsql.go
│ │ ├── collector.go
│ │ ├── collector_call.go
│ │ ├── collector_generic.go
│ │ ├── collector_integration_test.go
│ │ ├── collector_rg_call.go
│ │ ├── collector_test.go
│ │ ├── kubernetes_cluster.go
│ │ ├── kubernetes_cluster_test.go
│ │ ├── load_balancer.go
│ │ ├── metrics.go
│ │ ├── network.go
│ │ ├── network_test.go
│ │ ├── ratelimiter.go
│ │ ├── resource_group.go
│ │ ├── resource_group_test.go
│ │ ├── scc_findings.go
│ │ ├── scc_findings_test.go
│ │ ├── service_account.go
│ │ ├── service_account_test.go
│ │ ├── spanner.go
│ │ └── vm_instance.go
│ └── testcollector
│ │ └── testcollector.go
├── common
│ └── protoutils.go
├── constants
│ ├── constants.go
│ ├── context.go
│ └── gcp_sa_projects.go
├── engine
│ ├── framework.go
│ ├── framework_test.go
│ ├── rules
│ │ ├── api_key_overbroad_scope.go
│ │ ├── api_key_overbroad_scope_test.go
│ │ ├── bucket_is_public.go
│ │ ├── bucket_is_public_test.go
│ │ ├── cluster_nodes_have_public_ips.go
│ │ ├── cluster_nodes_have_public_ips_test.go
│ │ ├── common.go
│ │ ├── container_running.go
│ │ ├── container_running_test.go
│ │ ├── cross_environment_permission.go
│ │ ├── cross_project_permissions.go
│ │ ├── cross_project_permissions_test.go
│ │ ├── database_allows_unencrypted_connections.go
│ │ ├── database_allows_unencrypted_connections_test.go
│ │ ├── database_authorized_network_not_set_test.go
│ │ ├── database_authorized_networks_not_set.go
│ │ ├── exported_key_expiry_too_long.go
│ │ ├── exported_key_expiry_too_long_test.go
│ │ ├── exported_key_with_admin_privileges.go
│ │ ├── exported_key_with_admin_privileges_test.go
│ │ ├── human_with_overprivileged_basic_role.go
│ │ ├── human_with_overprivileged_basic_role_test.go
│ │ ├── iap_disabled.go
│ │ ├── iap_disabled_test.go
│ │ ├── kubernetes_vulnerability_scanning.go
│ │ ├── kubernetes_vulnerability_scanning_e2e_test.go
│ │ ├── kubernetes_vulnerability_scanning_test.go
│ │ ├── lb_tls_cert_expiring_soon.go
│ │ ├── lb_tls_cert_expiring_soon_test.go
│ │ ├── lb_tls_min_version_too_old.go
│ │ ├── lb_tls_min_version_too_old_test.go
│ │ ├── lb_user_managed_cert.go
│ │ ├── lb_user_managed_cert_test.go
│ │ ├── master_authorized_neworks_not_set.go
│ │ ├── master_authorized_neworks_not_set_test.go
│ │ ├── outdated_kubernetes_version.go
│ │ ├── outdated_kubernetes_version_test.go
│ │ ├── private_google_access_disabled.go
│ │ ├── private_google_access_disabled_test.go
│ │ ├── registry.go
│ │ ├── registry_test.go
│ │ ├── svc_account_too_high_privileges.go
│ │ ├── svc_account_too_high_privileges_test.go
│ │ ├── testing.go
│ │ ├── testing_e2e.go
│ │ ├── unused_exported_credentials.go
│ │ ├── unused_exported_credentials_test.go
│ │ ├── vm_has_public_ip.go
│ │ └── vm_has_public_ip_test.go
│ ├── runner.go
│ ├── runner_integration_test.go
│ └── runner_test.go
├── go.mod
├── go.sum
├── log.go
├── lognotifier
│ └── lognotifier.go
├── main.go
├── metric
│ ├── keys.go
│ └── status.go
├── model
│ ├── acl.go
│ ├── collector.go
│ ├── engine.go
│ ├── exception.go
│ ├── model.go
│ ├── stateManager.go
│ └── storage.go
├── nagatha
│ ├── convert.go
│ ├── nagatha.go
│ ├── notification.go
│ ├── notification_test.go
│ ├── proto
│ │ └── nagatha.proto
│ ├── rest.go
│ └── rest_test.go
├── proto
│ ├── .gitignore
│ ├── generated
│ │ ├── go.mod
│ │ └── go.sum
│ ├── modron.proto
│ └── notification.proto
├── risk
│ └── risk.go
├── server.go
├── service
│ ├── mock_notifier_test.go
│ ├── service.go
│ └── service_test.go
├── statemanager
│ └── reqdepstatemanager
│ │ ├── requestDependenciesStateManager.go
│ │ └── requestDependenciesStateManager_test.go
├── storage
│ ├── gormstorage
│ │ ├── gorm.go
│ │ ├── gorm_integration_test.go
│ │ ├── gorm_test.go
│ │ ├── impact.go
│ │ ├── model.go
│ │ ├── observation_category.go
│ │ ├── observation_source.go
│ │ ├── operation.go
│ │ └── severity.go
│ ├── memstorage
│ │ └── memstorage.go
│ ├── storage.go
│ ├── test
│ │ └── test.go
│ └── utils
│ │ └── utils.go
├── test
│ ├── e2e_test.go
│ ├── fake_notification_service.go
│ ├── go.mod
│ └── go.sum
├── ui
│ ├── .dockerignore
│ ├── .gcloudignore
│ ├── .gitignore
│ ├── .yarnrc.yml
│ ├── Dockerfile
│ ├── README.md
│ ├── client
│ │ ├── .browserslistrc
│ │ ├── .dockerignore
│ │ ├── .editorconfig
│ │ ├── .eslintignore
│ │ ├── .eslintrc.json
│ │ ├── Dockerfile
│ │ ├── Dockerfile.e2e
│ │ ├── README.md
│ │ ├── angular.json
│ │ ├── cypress.config.ts
│ │ ├── cypress
│ │ │ ├── .gitignore
│ │ │ ├── e2e
│ │ │ │ └── spec.cy.ts
│ │ │ ├── support
│ │ │ │ └── component-index.html
│ │ │ └── tsconfig.json
│ │ ├── karma.conf.js
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── .gitignore
│ │ │ ├── app
│ │ │ │ ├── app-routing.module.ts
│ │ │ │ ├── app.component.html
│ │ │ │ ├── app.component.scss
│ │ │ │ ├── app.component.spec.ts
│ │ │ │ ├── app.component.ts
│ │ │ │ ├── app.module.ts
│ │ │ │ ├── authentication.service.spec.ts
│ │ │ │ ├── authentication.service.ts
│ │ │ │ ├── filter.pipe.spec.ts
│ │ │ │ ├── filter.pipe.ts
│ │ │ │ ├── grpc-token.interceptor.ts
│ │ │ │ ├── impact-indicator
│ │ │ │ │ ├── impact-indicator.component.html
│ │ │ │ │ ├── impact-indicator.component.scss
│ │ │ │ │ └── impact-indicator.component.ts
│ │ │ │ ├── model
│ │ │ │ │ ├── modron.model.ts
│ │ │ │ │ └── notification.model.ts
│ │ │ │ ├── modron-app
│ │ │ │ │ ├── modron-app.component.html
│ │ │ │ │ ├── modron-app.component.scss
│ │ │ │ │ ├── modron-app.component.spec.ts
│ │ │ │ │ └── modron-app.component.ts
│ │ │ │ ├── modron.service.spec.ts
│ │ │ │ ├── modron.service.ts
│ │ │ │ ├── notif-bell-button
│ │ │ │ │ ├── notif-bell-button.component.html
│ │ │ │ │ ├── notif-bell-button.component.scss
│ │ │ │ │ └── notif-bell-button.component.ts
│ │ │ │ ├── notification-exception-form
│ │ │ │ │ ├── notification-exception-form.component.html
│ │ │ │ │ ├── notification-exception-form.component.scss
│ │ │ │ │ ├── notification-exception-form.component.spec.ts
│ │ │ │ │ └── notification-exception-form.component.ts
│ │ │ │ ├── notification-exceptions
│ │ │ │ │ ├── notification-exceptions.component.html
│ │ │ │ │ ├── notification-exceptions.component.scss
│ │ │ │ │ ├── notification-exceptions.component.spec.ts
│ │ │ │ │ ├── notification-exceptions.component.ts
│ │ │ │ │ └── notification-exceptions.pipe.ts
│ │ │ │ ├── notification.service.spec.ts
│ │ │ │ ├── notification.service.ts
│ │ │ │ ├── observation-details-dialog-content
│ │ │ │ │ ├── observation-details-dialog-content.component.html
│ │ │ │ │ ├── observation-details-dialog-content.component.scss
│ │ │ │ │ ├── observation-details-dialog-content.component.ts
│ │ │ │ │ └── observation-details-dialog-content.filter.ts
│ │ │ │ ├── observation-details-dialog
│ │ │ │ │ ├── observation-details-dialog.component.html
│ │ │ │ │ ├── observation-details-dialog.component.scss
│ │ │ │ │ └── observation-details-dialog.component.ts
│ │ │ │ ├── observation-details
│ │ │ │ │ ├── observation-details.component.html
│ │ │ │ │ ├── observation-details.component.scss
│ │ │ │ │ ├── observation-details.component.spec.ts
│ │ │ │ │ └── observation-details.component.ts
│ │ │ │ ├── observations-stats
│ │ │ │ │ ├── observations-stats.component.html
│ │ │ │ │ ├── observations-stats.component.scss
│ │ │ │ │ ├── observations-stats.component.spec.ts
│ │ │ │ │ └── observations-stats.component.ts
│ │ │ │ ├── observations-table
│ │ │ │ │ ├── observations-table.component.html
│ │ │ │ │ ├── observations-table.component.scss
│ │ │ │ │ └── observations-table.component.ts
│ │ │ │ ├── resource-group-details
│ │ │ │ │ ├── resource-group-details.component.html
│ │ │ │ │ ├── resource-group-details.component.scss
│ │ │ │ │ ├── resource-group-details.component.spec.ts
│ │ │ │ │ ├── resource-group-details.component.ts
│ │ │ │ │ └── resource-group-details.pipe.ts
│ │ │ │ ├── resource-group
│ │ │ │ │ ├── resource-group.component.html
│ │ │ │ │ ├── resource-group.component.scss
│ │ │ │ │ ├── resource-group.component.spec.ts
│ │ │ │ │ ├── resource-group.component.ts
│ │ │ │ │ └── resource-group.pipe.ts
│ │ │ │ ├── resource-groups
│ │ │ │ │ ├── resource-groups.component.html
│ │ │ │ │ ├── resource-groups.component.scss
│ │ │ │ │ ├── resource-groups.component.spec.ts
│ │ │ │ │ ├── resource-groups.component.ts
│ │ │ │ │ └── resource-groups.pipe.ts
│ │ │ │ ├── search-obs
│ │ │ │ │ ├── search-obs.component.html
│ │ │ │ │ ├── search-obs.component.scss
│ │ │ │ │ ├── search-obs.component.spec.ts
│ │ │ │ │ └── search-obs.component.ts
│ │ │ │ ├── severity-indicator
│ │ │ │ │ ├── severity-indicator.component.html
│ │ │ │ │ ├── severity-indicator.component.scss
│ │ │ │ │ ├── severity-indicator.component.ts
│ │ │ │ │ └── severity-indicator.pipe.ts
│ │ │ │ ├── sidenav
│ │ │ │ │ ├── sidenav.component.html
│ │ │ │ │ ├── sidenav.component.scss
│ │ │ │ │ ├── sidenav.component.spec.ts
│ │ │ │ │ └── sidenav.component.ts
│ │ │ │ ├── state
│ │ │ │ │ ├── authentication.store.ts
│ │ │ │ │ ├── modron.store.ts
│ │ │ │ │ └── notification.store.ts
│ │ │ │ ├── stats.service.spec.ts
│ │ │ │ ├── stats.service.ts
│ │ │ │ ├── stats
│ │ │ │ │ ├── stats.component.html
│ │ │ │ │ ├── stats.component.scss
│ │ │ │ │ ├── stats.component.spec.ts
│ │ │ │ │ └── stats.component.ts
│ │ │ │ ├── token.interceptor.ts
│ │ │ │ └── ui-demo
│ │ │ │ │ ├── ui-demo.component.html
│ │ │ │ │ ├── ui-demo.component.scss
│ │ │ │ │ └── ui-demo.component.ts
│ │ │ ├── assets
│ │ │ │ ├── .gitkeep
│ │ │ │ ├── modron-white.svg
│ │ │ │ └── modron.svg
│ │ │ ├── colors.scss
│ │ │ ├── environments
│ │ │ │ ├── environment.base.ts
│ │ │ │ ├── environment.local.ts
│ │ │ │ ├── environment.prod.ts
│ │ │ │ └── environment.ts
│ │ │ ├── favicon.png
│ │ │ ├── index.html
│ │ │ ├── main.ts
│ │ │ ├── polyfills.ts
│ │ │ ├── styles.scss
│ │ │ └── test.ts
│ │ ├── tsconfig.app.json
│ │ ├── tsconfig.json
│ │ └── tsconfig.spec.json
│ ├── docker-compose.yml
│ ├── go.mod
│ ├── go.sum
│ ├── mock-grpc-server
│ │ ├── .dockerignore
│ │ ├── Dockerfile
│ │ ├── copy-proto.sh
│ │ ├── envoy.yaml
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── proto
│ │ │ └── .gitkeep
│ │ ├── server.ts
│ │ └── tsconfig.json
│ ├── package-lock.json
│ ├── package.json
│ ├── proxy.conf.json
│ └── server.go
├── utils
│ ├── gcp.go
│ ├── gke.go
│ ├── gke_test.go
│ ├── groups.go
│ ├── hierarchy.go
│ ├── keys.go
│ ├── keys_test.go
│ ├── name.go
│ ├── name_test.go
│ ├── protobuf.go
│ ├── protobuf_test.go
│ ├── ref.go
│ ├── resource_ref.go
│ ├── rule.go
│ ├── service_account.go
│ └── service_account_test.go
└── validation.go
├── terraform
├── README.md
├── dev
│ └── main.tf.example
├── modron
│ ├── artifact_registry.tf
│ ├── cloud_run.tf
│ ├── cloud_sql.tf
│ ├── dns_logging.tf
│ ├── gitlab.tf
│ ├── iap.tf
│ ├── load_balancer.tf
│ ├── main.tf
│ ├── network.tf
│ ├── otel
│ │ ├── README.md
│ │ └── config.yaml
│ ├── project.tf
│ ├── secret.tf
│ ├── service_account.tf
│ ├── services.tf
│ ├── terraform.tf
│ ├── tracing.tf
│ └── variables.tf
└── prod
│ └── main.tf.example
└── utils
└── gcp_service_agents
├── .gitignore
├── README.md
├── go.mod
├── go.sum
└── main.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/node_modules
2 | **/.angular
3 | /.git
4 | /terraform
--------------------------------------------------------------------------------
/.git-cc.yaml:
--------------------------------------------------------------------------------
1 | scopes:
2 | collector: Collector changes
3 | docker: Docker related changes
4 | nagatha: Nagatha related changes
5 | otel: OpenTelemetry related changes
6 | rules: Changes to the rules
7 | scc: Security Command Center related changes
8 | server: Changes to the Modron backend
9 | storage: Changes to the storage layer
10 | terraform: Terraform related changes
11 | ui: UI changes
12 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Go
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Set up Go
16 | uses: actions/setup-go@v3
17 | with:
18 | go-version: 1.21
19 |
20 | - name: Go Test
21 | working-directory: src
22 | run: go mod tidy && go test --short --timeout 5s ./...
23 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | linters:
2 | disable-all: true
3 | enable:
4 | - contextcheck
5 | - errcheck
6 | - errorlint
7 | - gocritic
8 | - mnd
9 | - gosec
10 | - gosimple
11 | - govet
12 | - ineffassign
13 | - misspell
14 | - nestif
15 | - nilerr
16 | - nilnil
17 | - revive
18 | - staticcheck
19 | - typecheck
20 | - unused
21 | - wastedassign
22 |
23 | issues:
24 | exclude-dirs:
25 | - "terraform"
26 | run:
27 | timeout: 5m
28 |
29 | output:
30 | formats:
31 | - format: colored-line-number
32 | path: stdout
33 | - format: junit-xml
34 | path: modron-lint.xml
35 |
--------------------------------------------------------------------------------
/.semgrepignore:
--------------------------------------------------------------------------------
1 | # Ignore generated code
2 | *_pb_service.d.ts
3 | *_pb_service.js
4 | *_pb.d.ts
5 | *_pb.js
6 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## v1.0.0
4 |
5 | ### Structure
6 |
7 | - Support Resource Group Hierarchy
8 |
9 | ### Observations
10 |
11 | - Add [Risk Score](docs/RISK_SCORE.md) to observations, calculated from the severity of the observation (as defined in the rule) and the impact of the observation (detected from the environment)
12 | - Collectors can now collect observations
13 |
14 | ### Stats
15 |
16 | - Improved stats view
17 | - Improved export to CSV
18 |
19 | ### GCP
20 |
21 | - Add support for [Security Command Center (SCC)](https://cloud.google.com/security-command-center/docs/concepts-security-command-center-overview)
22 | - Start collecting Kubernetes resources
23 |
24 | ### Storage
25 |
26 | - Use [GORM](https://gorm.io/) for both the PSQL and SQLite storage backends
27 | - Use SQLite for the in-memory database for testing
28 |
29 | ### Performance
30 |
31 | - Increase performance overall by optimizing the DB queries, parallelizing the scans, and reducing the number of external calls
32 | - Introduce rate limiting for the collectors
33 |
34 | ### Observability
35 |
36 | - Use [logrus](https://github.com/sirupsen/logrus) with structured logging for GCP Logging (Stackdriver)
37 | - Add support for OpenTelemetry
38 | - Add an otel-collector to receive traces and metrics
39 | - Send traces to [Google Cloud Trace](https://cloud.google.com/trace)
40 | - Send metrics to [Google Cloud Monitoring](https://cloud.google.com/monitoring)
41 |
42 | ### UI
43 |
44 | - Completely rework the UI with an improved design
45 | - Show observations as a table, sorted by Risk Score by default
46 | - Add a detailed view dialog for the observations
47 |
48 | ### Misc
49 |
50 | - Use [`go-arg`](https://github.com/alexflint/go-arg) for the CLI arguments / environment variables
51 | - Switch to [buf](https://buf.build/) for the protobuf generation
52 | - Bug fixes
53 | - Upgrade to Go 1.23
54 | - Rules now support external configuration
55 |
56 | ## v0.2
57 |
58 | - Moved to go 1.19
59 | - Added automated runs for scans
60 | - Fixed issue where last reported observation would still appear even if newer scans reported no observations
61 | - Fixed group member ship resolution when checking for accesses to GCP projects
62 |
63 | ## v0.1
64 |
65 | - Initial public release
66 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright 2022 Niantic Inc.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/buf.gen.yaml:
--------------------------------------------------------------------------------
1 | version: v2
2 | managed:
3 | enabled: true
4 |
5 | plugins:
6 | - local: protoc-gen-go
7 | out: src/proto/generated
8 | - local: protoc-gen-go-grpc
9 | out: src/proto/generated
10 | - local: protoc-gen-js
11 | out: src/ui/client/src/proto/
12 | opt: import_style=commonjs,binary
13 | - local: protoc-gen-grpc-web
14 | out: src/ui/client/src/proto/
15 | opt:
16 | - import_style=typescript
17 | - mode=grpcweb
18 |
19 | inputs:
20 | - directory: ./src/proto
21 | - directory: ./src/nagatha/proto
22 | - module: buf.build/googleapis/googleapis:8bc2c51e08c447cd8886cdea48a73e14
23 | paths:
24 | - google/api
25 | - google/rpc
26 | - google/longrunning
27 | - module: buf.build/k8s/api:8f68e41b943c4de8a5e9c9a921c889a7
28 | paths:
29 | - k8s.io/api/core
30 | - k8s.io/apimachinery/
--------------------------------------------------------------------------------
/buf.lock:
--------------------------------------------------------------------------------
1 | # Generated by buf. DO NOT EDIT.
2 | version: v2
3 | deps:
4 | - name: buf.build/googleapis/googleapis
5 | commit: 8bc2c51e08c447cd8886cdea48a73e14
6 | digest: b5:b7e0ac9d192bd0eae88160101269550281448c51f25121cd0d51957661a350aab07001bc145fe9029a8da10b99ff000ae5b284ecaca9c75f2a99604a04d9b4ab
7 | - name: buf.build/k8s/api
8 | commit: 8f68e41b943c4de8a5e9c9a921c889a7
9 | digest: b5:0c188e351df7b094d6a5412f4cd5f097fbf1a32d4a2d4c42b83774e168961447e6e706e4ebf241a13b94493aa6cbe08dc8abd03e2a1f8207ac7620bf186030c8
10 |
--------------------------------------------------------------------------------
/buf.yaml:
--------------------------------------------------------------------------------
1 | version: v2
2 | modules:
3 | - path: src/proto
4 | - path: src/nagatha/proto
5 | deps:
6 | - buf.build/googleapis/googleapis
7 | - buf.build/k8s/api
8 | lint:
9 | use:
10 | - DEFAULT
--------------------------------------------------------------------------------
/cloudbuild-ui.yaml:
--------------------------------------------------------------------------------
1 | steps:
2 | - name: 'gcr.io/cloud-builders/docker'
3 | args:
4 | - build
5 | - --tag=us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron-ui:$_TAG_REF_1
6 | - --tag=us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron-ui:$_TAG_REF_2
7 | - -f
8 | - ./src/ui/Dockerfile
9 | - .
10 | images:
11 | - "us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron-ui:$_TAG_REF_1"
12 | - "us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron-ui:$_TAG_REF_2"
13 |
--------------------------------------------------------------------------------
/cloudbuild.yaml:
--------------------------------------------------------------------------------
1 | steps:
2 | - name: 'gcr.io/cloud-builders/docker'
3 | args:
4 | - build
5 | - --tag=us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron:$_TAG_REF_1
6 | - --tag=us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron:$_TAG_REF_2
7 | - -f
8 | - ./src/Dockerfile
9 | - .
10 | images:
11 | - "us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron:$_TAG_REF_1"
12 | - "us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron:$_TAG_REF_2"
--------------------------------------------------------------------------------
/docker-compose.dev.yaml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | postgres_db:
5 | container_name: postgres_db
6 | image: postgres:16
7 | restart: always
8 | environment:
9 | POSTGRES_USER: "modron"
10 | POSTGRES_PASSWORD: "modron"
11 | POSTGRES_DB: "modron"
12 | PGDATA: "/tmp/"
13 | ports:
14 | - "5432:5432"
15 | healthcheck:
16 | test: ["CMD-SHELL", "pg_isready -U modron"]
17 | interval: 1s
18 | timeout: 2s
19 | retries: 5
20 | tmpfs:
21 | - /tmp
22 |
23 | jaeger:
24 | image: jaegertracing/all-in-one:1.59
25 | ports:
26 | - "16686:16686"
27 | environment:
28 | COLLECTOR_OTLP_ENABLED: true
29 | COLLECTOR_OTLP_GRPC_HOST_PORT: 0.0.0.0:4317
30 | networks:
31 | - otel
32 |
33 | prometheus:
34 | image: prom/prometheus:latest
35 | command:
36 | - --web.enable-remote-write-receiver
37 | ports:
38 | - "9090:9090"
39 | networks:
40 | - otel
41 |
42 | otel-collector:
43 | image: otel/opentelemetry-collector:0.108.0
44 | command:
45 | - --config=/etc/otel/config.yaml
46 | ports:
47 | - "4317:4317"
48 | volumes:
49 | - ./otel/config:/etc/otel
50 | networks:
51 | - otel
52 | networks:
53 | otel: {}
54 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | postgres_db:
3 | container_name: postgres_db
4 | image: postgres:14-bookworm
5 | restart: always
6 | ports:
7 | - "5432:5432"
8 | environment:
9 | POSTGRES_USER: "modron"
10 | POSTGRES_PASSWORD: "docker-test-password"
11 | POSTGRES_DB: "modron"
12 | PGDATA: "/tmp/"
13 | healthcheck:
14 | test: ["CMD-SHELL", "pg_isready -U modron"]
15 | interval: 1s
16 | timeout: 2s
17 | retries: 5
18 | tmpfs:
19 | - /tmp
20 | networks:
21 | - modron
22 |
23 | modron_fake:
24 | container_name: modron_fake
25 | build:
26 | context: .
27 | dockerfile: src/Dockerfile
28 | environment:
29 | COLLECTOR: "FAKE"
30 | DB_BATCH_SIZE: "1"
31 | DB_MAX_CONNECTIONS: "1"
32 | IS_E2E_GRPC_TEST: "true"
33 | LISTEN_ADDR: "0.0.0.0"
34 | NOTIFICATION_SERVICE: "modron_test:8082"
35 | ORG_ID: "111111111111"
36 | ORG_SUFFIX: "@example.com"
37 | PORT: 8081
38 | RUN_AUTOMATED_SCANS: "false"
39 | SQL_BACKEND_DRIVER: "postgres"
40 | SQL_CONNECT_STRING: "host=postgres_db port=5432 user=modron password=docker-test-password database=modron sslmode=disable"
41 | STORAGE: "SQL"
42 | TAG_CUSTOMER_DATA: 111111111111/customer_data
43 | TAG_EMPLOYEE_DATA: 111111111111/employee_data
44 | TAG_ENVIRONMENT: 111111111111/environment
45 | ports:
46 | - "8081:8081"
47 | networks:
48 | - modron
49 | depends_on:
50 | postgres_db:
51 | condition: service_healthy
52 |
53 | modron_test:
54 | container_name: e2e_test
55 | build:
56 | context: .
57 | dockerfile: src/Dockerfile.e2e
58 | environment:
59 | BACKEND_ADDRESS: "modron:8080"
60 | FAKE_BACKEND_ADDRESS: "modron_fake:8081"
61 | FAKE_NOTIFICATION_SERVICE_PORT: "8082"
62 | networks:
63 | - modron
64 | depends_on:
65 | - modron_fake
66 |
67 | networks:
68 | modron:
69 | driver: bridge
70 |
--------------------------------------------------------------------------------
/docker/cypress/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "cypress"
2 |
3 | export default defineConfig({
4 |
5 | e2e: {
6 | baseUrl: "http://localhost:8080",
7 | supportFile: false
8 | },
9 | video: false,
10 | screenshotOnRunFailure: false,
11 | component: {
12 | devServer: {
13 | framework: "angular",
14 | bundler: "webpack",
15 | },
16 | specPattern: "**/*.cy.ts"
17 | },
18 |
19 | reporter: "junit",
20 | reporterOptions: {
21 | mochaFile: "/app/results/modron-e2e-ui-junit.xml",
22 | toConsole: false,
23 | },
24 |
25 | })
26 |
--------------------------------------------------------------------------------
/docker/cypress/tsconfig.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "compileOnSave": false,
4 | "compilerOptions": {
5 | "baseUrl": "./",
6 | "outDir": "./dist/out-tsc",
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "noImplicitOverride": true,
10 | "noPropertyAccessFromIndexSignature": true,
11 | "noImplicitReturns": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "sourceMap": true,
14 | "declaration": false,
15 | "downlevelIteration": true,
16 | "experimentalDecorators": true,
17 | "moduleResolution": "node",
18 | "importHelpers": true,
19 | "target": "ES2022",
20 | "module": "ES2022",
21 | "lib": [
22 | "es2020",
23 | "dom"
24 | ],
25 | },
26 | "angularCompilerOptions": {
27 | "enableI18nLegacyMessageIdFormat": false,
28 | "strictInjectionParameters": true,
29 | "strictInputAccessModifiers": true,
30 | "strictTemplates": true
31 | },
32 | "exclude": [
33 | "cypress.config.ts"
34 | ],
35 | "files": [
36 | "cypress.config.ts"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/docs/pics/risk_score_matrix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/pics/risk_score_matrix.png
--------------------------------------------------------------------------------
/docs/pics/severities.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/pics/severities.png
--------------------------------------------------------------------------------
/docs/screenshots/home.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/home.jpg
--------------------------------------------------------------------------------
/docs/screenshots/nagatha-1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/nagatha-1.jpg
--------------------------------------------------------------------------------
/docs/screenshots/nagatha-2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/nagatha-2.jpg
--------------------------------------------------------------------------------
/docs/screenshots/observations.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/observations.jpg
--------------------------------------------------------------------------------
/docs/screenshots/risk-score.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/risk-score.jpg
--------------------------------------------------------------------------------
/docs/screenshots/single-observation.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/single-observation.jpg
--------------------------------------------------------------------------------
/docs/screenshots/stats.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/stats.jpg
--------------------------------------------------------------------------------
/env.example:
--------------------------------------------------------------------------------
1 | ADMIN_GROUPS=modron-admins@example.com
2 | COLLECTORS=gcp
3 | LABEL_TO_EMAIL_REGEXP=(.*)_(.*?)_(.*?)
4 | LABEL_TO_EMAIL_SUBSTITUTION=$1@$2.$3
5 | LOG_FORMAT=text
6 | LOG_LEVEL=info
7 | LOG_ALL_SQL_QUERIES=false
8 | ORG_ID=111111111111
9 | ORG_SUFFIX=example.com
10 | PERSISTENT_CACHE=true
11 | PERSISTENT_CACHE_TIMEOUT=48h
12 | PORT=4201
13 | PROJECT_ID=modron-project-id
14 | RUN_AUTOMATED_SCANS=false
15 | SKIP_IAP=true
16 | SQL_CONNECT_STRING=postgres://modron:modron@127.0.0.1:5432/modron?sslmode=disable
17 |
18 | OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
19 | OTEL_EXPORTER_OTLP_INSECURE=true
20 | OTEL_LOG_LEVEL=debug
21 | OTEL_SERVICE_NAME=modron
22 |
23 | TAG_CUSTOMER_DATA=111111111111/customer_data
24 | TAG_EMPLOYEE_DATA=111111111111/employee_data
25 | TAG_ENVIRONMENT=111111111111/environment
26 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | // This file is required for gosec to work.
2 | module github.com/nianticlabs/modron
3 |
4 | go 1.23.2
5 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/go.sum
--------------------------------------------------------------------------------
/images/scan_process.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/images/scan_process.png
--------------------------------------------------------------------------------
/otel/config/config.yaml:
--------------------------------------------------------------------------------
1 | receivers:
2 | otlp:
3 | protocols:
4 | grpc:
5 | endpoint: 0.0.0.0:4317
6 | http:
7 | endpoint: 0.0.0.0:4318
8 | processors:
9 | batch:
10 |
11 | exporters:
12 | otlp:
13 | endpoint: "jaeger:4317"
14 | tls:
15 | insecure: true
16 | prometheusremotewrite:
17 | endpoint: "http://prometheus:9090/api/v1/write"
18 |
19 | extensions:
20 | health_check:
21 | pprof:
22 | zpages:
23 |
24 | service:
25 | extensions: [health_check, pprof, zpages]
26 | pipelines:
27 | traces:
28 | receivers: [otlp]
29 | processors: [batch]
30 | exporters: [otlp]
31 | metrics:
32 | receivers: [otlp]
33 | processors: []
34 | exporters: [prometheusremotewrite]
--------------------------------------------------------------------------------
/src/.gcloudignore:
--------------------------------------------------------------------------------
1 | *~
2 | **/node_modules
3 | **/.angular
4 | test/certs/**
--------------------------------------------------------------------------------
/src/.gitignore:
--------------------------------------------------------------------------------
1 | /modron-lint.xml
2 |
--------------------------------------------------------------------------------
/src/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG GOVERSION=1.23
2 |
3 | FROM alpine:latest AS ca-certificates_builder
4 | RUN apk add --no-cache ca-certificates
5 |
6 | FROM golang:${GOVERSION} AS modron_builder
7 | ENV GOPATH=/go
8 | WORKDIR /app/src
9 | COPY src/go.* /app/src/
10 | COPY src/proto/generated /app/src/proto/generated/
11 | RUN go mod download
12 | COPY ./src/ /app/src/
13 | RUN CGO_ENABLED=0 go build -v -o modron
14 |
15 | FROM scratch
16 | WORKDIR /app
17 | COPY --from=ca-certificates_builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
18 | COPY --from=modron_builder /app/src/modron /app/modron
19 | USER 101:101
20 | ENTRYPOINT ["/app/modron"]
21 |
--------------------------------------------------------------------------------
/src/Dockerfile.e2e:
--------------------------------------------------------------------------------
1 | # We have to keep this file here otherwise we can't depend on the shared proto.
2 | # Docker prevents including files above the Dockerfile directory (.. forbidden).
3 | ARG GOVERSION=1.23
4 |
5 | FROM golang:${GOVERSION} AS builder
6 | ENV GOPATH=/go
7 | WORKDIR /app/src/test/
8 | COPY ./src/test/go.* /app/src/test/
9 | COPY ./src/proto/ /app/src/proto
10 | RUN go mod download
11 | COPY ./src/test/* /app/src/test/
12 | RUN CGO_ENABLED=0 go test -c -v -o test
13 |
14 | FROM scratch
15 | WORKDIR /app/stats
16 | WORKDIR /app/secrets
17 | WORKDIR /app
18 | COPY --from=builder /app/src/test/test /app/test
19 | ENTRYPOINT ["/app/test", "--test.short"]
20 |
--------------------------------------------------------------------------------
/src/README.md:
--------------------------------------------------------------------------------
1 | # Modron
2 |
3 | ## Build modron and push to Google Cloud Registry
4 |
5 | ```bash
6 | gcloud builds submit . --tag us-central1.docker.pkg.dev/$PROJECT_ID/modron/modron:dev --timeout=900
7 | ```
8 |
9 | This applies the label `dev` on the image you're pushing.
10 | This image is expected to run on modron-dev environment.
11 |
12 | Deploy to cloud run dev:
13 |
14 | ```bash
15 | DEV_RUNNER_SA_NAME=$PROJECT_ID-runner@$PROJECT_ID.iam.gserviceaccount.com
16 | REGION=us-central1
17 | gcloud run deploy \
18 | modron-grpc-web-dev \
19 | --platform=managed \
20 | --image="$REGION.docker.pkg.dev/$PROJECT_ID/modron/modron:dev" \
21 | --region="$REGION" \
22 | --service-account="$DEV_RUNNER_SA_NAME"
23 | gcloud run services update-traffic modron-ui --to-revisions LATEST=100 --region="$REGION"
24 | ```
25 |
26 | ## Debug
27 |
28 | To debug RPC issues, set the two following environment variables:
29 |
30 | ```bash
31 | export GRPC_GO_LOG_VERBOSITY_LEVEL=99
32 | export GRPC_GO_LOG_SEVERITY_LEVEL=info
33 | ```
34 |
35 | ## Update libraries
36 |
37 | ```bash
38 | export CYPRESS_CACHE_FOLDER=/tmp
39 | npm upgrade
40 | npm install
41 | ```
42 |
43 | Note: Cypress tries to write to `/root/.cache` which doesn't work. This is why we need to set the environment variable.
44 |
--------------------------------------------------------------------------------
/src/acl/fakeacl/checkerFake.go:
--------------------------------------------------------------------------------
1 | package fakeacl
2 |
3 | import (
4 | "github.com/sirupsen/logrus"
5 | "golang.org/x/net/context"
6 |
7 | "github.com/nianticlabs/modron/src/constants"
8 | "github.com/nianticlabs/modron/src/model"
9 | )
10 |
11 | type GcpCheckerFake struct{}
12 |
13 | var log = logrus.StandardLogger().WithField(constants.LogKeyPkg, "fakeacl")
14 | var _ model.Checker = (*GcpCheckerFake)(nil)
15 |
16 | func New() model.Checker {
17 | log.Warnf("If you see this on production, contact security")
18 | return &GcpCheckerFake{}
19 | }
20 |
21 | func (checker *GcpCheckerFake) GetACL() model.ACLCache {
22 | return nil
23 | }
24 |
25 | func (checker *GcpCheckerFake) GetValidatedUser(_ context.Context) (string, error) {
26 | return "", nil
27 | }
28 |
29 | func (checker *GcpCheckerFake) ListResourceGroupNamesOwned(_ context.Context) (map[string]struct{}, error) {
30 | return map[string]struct{}{"projects/modron-test": {}}, nil
31 | }
32 |
--------------------------------------------------------------------------------
/src/acl/gcpacl/cache.go:
--------------------------------------------------------------------------------
1 | package gcpacl
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "os"
7 | "time"
8 |
9 | "github.com/nianticlabs/modron/src/model"
10 | )
11 |
12 | type FSACLCache struct {
13 | LastUpdate time.Time `json:"last_update"`
14 | Content model.ACLCache `json:"content"`
15 | }
16 |
17 | var localACLCacheFile = os.TempDir() + "/modron-acl-cache.json"
18 |
19 | const ownerRWPermissions = 0600
20 |
21 | func (checker *GcpChecker) getLocalACLCache() (*FSACLCache, error) {
22 | log.Tracef("getting ACL cache from %s", localACLCacheFile)
23 | f, err := os.Open(localACLCacheFile)
24 | if err != nil {
25 | if errors.Is(err, os.ErrNotExist) {
26 | return nil, nil //nolint:nilnil
27 | }
28 | return nil, err
29 | }
30 | defer f.Close()
31 |
32 | var fsACLCache FSACLCache
33 | if err := json.NewDecoder(f).Decode(&fsACLCache); err != nil {
34 | return nil, err
35 | }
36 | return &fsACLCache, nil
37 | }
38 |
39 | func (checker *GcpChecker) saveLocalACLCache(res model.ACLCache) error {
40 | log.Tracef("saving ACL cache to %s", localACLCacheFile)
41 | fsACLCache := FSACLCache{
42 | LastUpdate: time.Now(),
43 | Content: res,
44 | }
45 | f, err := os.OpenFile(localACLCacheFile, os.O_CREATE|os.O_WRONLY, ownerRWPermissions)
46 | if err != nil {
47 | return err
48 | }
49 | defer f.Close()
50 | return json.NewEncoder(f).Encode(fsACLCache)
51 | }
52 |
53 | func (checker *GcpChecker) deleteLocalACLCache() error {
54 | return os.Remove(localACLCacheFile)
55 | }
56 |
--------------------------------------------------------------------------------
/src/acl/gcpacl/checker_real_test.go:
--------------------------------------------------------------------------------
1 | //go:build integration
2 |
3 | package gcpacl
4 |
5 | import (
6 | "context"
7 | "os"
8 | "testing"
9 |
10 | "github.com/nianticlabs/modron/src/collector/gcpcollector"
11 | "github.com/nianticlabs/modron/src/risk"
12 | "github.com/nianticlabs/modron/src/storage/memstorage"
13 | )
14 |
15 | func TestCheckerReal(t *testing.T) {
16 | orgID := os.Getenv("ORG_ID")
17 | orgSuffix := os.Getenv("ORG_SUFFIX")
18 | if orgID == "" || orgSuffix == "" {
19 | t.Fatalf("ORG_ID and ORG_SUFFIX are required, orgID=%q, orgSuffix=%q", orgID, orgSuffix)
20 | }
21 |
22 | ctx := context.Background()
23 | storage := memstorage.New()
24 | gcpCollector, err := gcpcollector.New(ctx, storage, orgID, orgSuffix, []string{}, risk.TagConfig{}, []string{})
25 | if err != nil {
26 | t.Error(err)
27 | }
28 |
29 | checker, err := New(ctx, gcpCollector, Config{})
30 | if err != nil {
31 | t.Fatal(err)
32 | }
33 |
34 | if _, err := checker.ListResourceGroupNamesOwned(ctx); err != nil {
35 | t.Error(err)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/acl/gcpacl/checker_test.go:
--------------------------------------------------------------------------------
1 | package gcpacl
2 |
3 | import (
4 | "context"
5 | "os"
6 | "testing"
7 |
8 | "github.com/nianticlabs/modron/src/collector/gcpcollector"
9 | "github.com/nianticlabs/modron/src/risk"
10 | "github.com/nianticlabs/modron/src/storage/memstorage"
11 |
12 | "google.golang.org/grpc/metadata"
13 | )
14 |
15 | func TestMain(m *testing.M) {
16 | os.Exit(m.Run())
17 | }
18 |
19 | func TestInvalidNoToken(t *testing.T) {
20 | if testing.Short() {
21 | t.Skip("skipping test in short mode: need GCP credentials")
22 | }
23 |
24 | ctx := context.Background()
25 | storage := memstorage.New()
26 | gcpCollector := gcpcollector.NewFake(ctx, storage, risk.TagConfig{})
27 |
28 | checker, err := New(ctx, gcpCollector, Config{})
29 | if err != nil {
30 | t.Error(err)
31 | }
32 |
33 | if _, err = checker.GetValidatedUser(ctx); err == nil {
34 | t.Error("expected error: the context does not have a tokenid but the checker authenticated a user")
35 | }
36 | }
37 |
38 | func TestInvalidParseToken(t *testing.T) {
39 | ctx := metadata.NewIncomingContext(context.Background(), metadata.New(
40 | map[string]string{"Authorization": "Bearer xyz.abc.123"}))
41 | storage := memstorage.New()
42 | gcpCollector := gcpcollector.NewFake(ctx, storage, risk.TagConfig{})
43 |
44 | checker, err := New(ctx, gcpCollector, Config{})
45 | if err != nil {
46 | t.Error(err)
47 | }
48 |
49 | if _, err = checker.GetValidatedUser(ctx); err == nil {
50 | t.Error("expected error: checker parsed a jwt tokenId that is invalid")
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/collector/collector.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | type Type string
4 |
5 | const (
6 | Gcp Type = "gcp"
7 | Fake Type = "fake"
8 | )
9 |
10 | var validCollectors = []Type{Gcp, Fake}
11 |
12 | func ValidCollectors() []string {
13 | var collectors []string
14 | for _, c := range validCollectors {
15 | collectors = append(collectors, string(c))
16 | }
17 | return collectors
18 | }
19 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/api_key.go:
--------------------------------------------------------------------------------
1 | package gcpcollector
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/nianticlabs/modron/src/common"
7 | "github.com/nianticlabs/modron/src/constants"
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 |
10 | "golang.org/x/net/context"
11 | "google.golang.org/api/apikeys/v2"
12 | )
13 |
14 | const (
15 | globalProjectResourceID = "%s/locations/global"
16 | )
17 |
18 | func (collector *GCPCollector) ListAPIKeys(ctx context.Context, rgName string) (apiKeys []*pb.Resource, err error) {
19 | name := fmt.Sprintf(globalProjectResourceID, constants.ResourceWithProjectsPrefix(rgName))
20 |
21 | keys, err := collector.api.ListAPIKeys(ctx, name)
22 | if err != nil {
23 | return nil, err
24 | }
25 | for _, key := range keys {
26 | // TODO : handle other types of GCP API keys restrictions
27 | // example : BrowserKeyRestrictions , AndroidKeyRestrictions , etc..
28 | scopes := getAPIKeyScopes(key)
29 | apiKeys = append(apiKeys, &pb.Resource{
30 | Uid: common.GetUUID(uuidGenRetries),
31 | ResourceGroupName: rgName,
32 | Name: key.Name,
33 | Parent: rgName,
34 | Type: &pb.Resource_ApiKey{
35 | ApiKey: &pb.APIKey{
36 | Scopes: scopes,
37 | },
38 | },
39 | })
40 | }
41 | return apiKeys, err
42 | }
43 |
44 | func getAPIKeyScopes(key *apikeys.V2Key) (scopes []string) {
45 | if key.Restrictions == nil || key.Restrictions.ApiTargets == nil {
46 | return nil
47 | }
48 | for _, apiTarget := range key.Restrictions.ApiTargets {
49 | scopes = append(scopes, apiTarget.Service)
50 | }
51 | return scopes
52 | }
53 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/collector_call.go:
--------------------------------------------------------------------------------
1 | package gcpcollector
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | "google.golang.org/api/googleapi"
8 | )
9 |
10 | const (
11 | maxAttemptNumber = 100
12 | maxSecBtwAttempts = 30.
13 | maxSecAcrossAttempts = time.Duration(3600 * time.Second)
14 | )
15 |
16 | var (
17 | // Retry following errors:
18 | // * 408: Request timeout
19 | // * 429: Too many requests
20 | // * 5XX: Server errors
21 | retryableErrorCode = []int{408, 429, 500, 502, 503, 504}
22 | // We are not interested in the following codes:
23 | // * 403: Sometimes returned for non-existing resources.
24 | // * 404: A resource can be tracked by modron and then deleted.
25 | skippableErrorCodes = []int{403, 404}
26 | )
27 |
28 | func getErrorCode(err error) int {
29 | var e *googleapi.Error
30 | if errors.As(err, &e) {
31 | return e.Code
32 | }
33 | return 0
34 | }
35 |
36 | func isErrorCodeRetryable(errorCode int) bool {
37 | for _, code := range retryableErrorCode {
38 | if code == errorCode {
39 | return true
40 | }
41 | }
42 | return false
43 | }
44 |
45 | func isErrorCodeSkippable(errorCode int) bool {
46 | for _, code := range skippableErrorCodes {
47 | if code == errorCode {
48 | return true
49 | }
50 | }
51 | return false
52 | }
53 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/collector_generic.go:
--------------------------------------------------------------------------------
1 | package gcpcollector
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "math"
7 | "math/rand"
8 | "time"
9 | )
10 |
11 | type GenericCollector[T any] func(ctx context.Context, rgName string) ([]T, error)
12 |
13 | func (call GenericCollector[T]) ExponentialBackoffRun(ctx context.Context, rgName string) ([]T, error) {
14 | attemptNumber := 1
15 | var waitSec float64
16 | start := time.Now()
17 | for {
18 | collected, err := call.Run(ctx, rgName)
19 | if err == nil {
20 | return collected, nil
21 | }
22 | if !isErrorCodeRetryable(getErrorCode(err)) {
23 | return nil, fmt.Errorf("ExponentialBackoffRun.notRetryableCode: %w ", err)
24 | }
25 | if time.Since(start) > maxSecAcrossAttempts {
26 | return nil, fmt.Errorf("ExponentialBackoffRun.tooLong: after %v seconds %w ", time.Since(start), err)
27 | }
28 | if attemptNumber >= maxAttemptNumber {
29 | return nil, fmt.Errorf("ExponentialBackoffRun.maxAttempt: after %v attempts %w ", attemptNumber, err)
30 | }
31 | waitSec = math.Min(math.Pow(2, float64(attemptNumber))+rand.Float64(), maxSecBtwAttempts) //nolint:mnd,gosec
32 | time.Sleep(time.Duration(waitSec) * time.Second)
33 | attemptNumber++
34 | }
35 | }
36 |
37 | func (call GenericCollector[T]) Run(ctx context.Context, rgName string) ([]T, error) {
38 | resources, err := call(ctx, rgName)
39 | if isErrorCodeSkippable(getErrorCode(err)) {
40 | return []T{}, nil
41 | }
42 | return resources, err
43 | }
44 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/collector_rg_call.go:
--------------------------------------------------------------------------------
1 | package gcpcollector
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "math"
7 | "math/rand"
8 | "time"
9 |
10 | pb "github.com/nianticlabs/modron/src/proto/generated"
11 | )
12 |
13 | // TODO: find a way to avoid code duplication:
14 | // (the implementation of CollectorResourceGroupCall.ExponentialBackoffRun is very similar to GenericCollector.ExponentialBackoffRun)
15 | type CollectorResourceGroupCall func(ctx context.Context, collectID string, rgName string) (*pb.Resource, error)
16 |
17 | func (call CollectorResourceGroupCall) ExponentialBackoffRun(ctx context.Context, collectID string, rgName string) (*pb.Resource, error) {
18 | attemptNumber := 0
19 | var waitSec float64
20 | start := time.Now()
21 | for {
22 | resources, err := call.Run(ctx, collectID, rgName)
23 | if err == nil {
24 | return resources, nil
25 | }
26 | if !isErrorCodeRetryable(getErrorCode(err)) {
27 | return nil, fmt.Errorf("ExponentialBackoffRun.notRetryableCode: %w ", err)
28 | }
29 | if time.Since(start) > maxSecAcrossAttempts {
30 | return nil, fmt.Errorf("ExponentialBackoffRun.tooLong: after %v seconds %w", time.Since(start), err)
31 | }
32 | if attemptNumber >= maxAttemptNumber {
33 | return nil, fmt.Errorf("ExponentialBackoffRun.maxAttempt: after %v attempts %w", attemptNumber, err)
34 | }
35 | waitSec = math.Min(math.Pow(2, float64(attemptNumber))+rand.Float64(), maxSecBtwAttempts) //nolint:mnd,gosec
36 | time.Sleep(time.Duration(waitSec) * time.Second)
37 | attemptNumber++
38 | }
39 | }
40 |
41 | func (call CollectorResourceGroupCall) Run(ctx context.Context, collectID string, rgName string) (*pb.Resource, error) {
42 | return call(ctx, collectID, rgName)
43 | }
44 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/metrics.go:
--------------------------------------------------------------------------------
1 | package gcpcollector
2 |
3 | import (
4 | "go.opentelemetry.io/otel"
5 | "go.opentelemetry.io/otel/metric"
6 |
7 | "github.com/nianticlabs/modron/src/constants"
8 | )
9 |
10 | var meter = otel.Meter("github.com/nianticlabs/modron/src/collector/gcpcollector")
11 |
12 | type metrics struct {
13 | SccCollectedObservations metric.Int64Counter
14 | }
15 |
16 | func initMetrics() metrics {
17 | sccCollectedObsCounter, err := meter.Int64Counter(constants.MetricsPrefix+"scc_collected_observations",
18 | metric.WithDescription("Number of collected observations from SCC"),
19 | )
20 | if err != nil {
21 | log.Errorf("failed to create scc_collected_observations counter: %v", err)
22 | }
23 | return metrics{
24 | SccCollectedObservations: sccCollectedObsCounter,
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/resource_group_test.go:
--------------------------------------------------------------------------------
1 | package gcpcollector
2 |
3 | import "testing"
4 |
5 | func TestGetResourceGroupLink(t *testing.T) {
6 | tc := []struct {
7 | name string
8 | expected string
9 | }{
10 | {
11 | name: "projects/project-1",
12 | expected: "https://console.cloud.google.com/welcome?project=project-1",
13 | },
14 | {
15 | name: "folders/1000000",
16 | expected: "https://console.cloud.google.com/welcome?folder=1000000",
17 | },
18 | {
19 | name: "organizations/1000000",
20 | expected: "https://console.cloud.google.com/welcome?organizationId=1000000",
21 | },
22 | }
23 |
24 | for _, tt := range tc {
25 | t.Run(tt.name, func(t *testing.T) {
26 | if got := getResourceGroupLink(tt.name); got != tt.expected {
27 | t.Errorf("GetResourceGroupLink() = %v, want %v", got, tt.expected)
28 | }
29 | })
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/service_account_test.go:
--------------------------------------------------------------------------------
1 | //go:build integration
2 |
3 | package gcpcollector
4 |
5 | import (
6 | "context"
7 | "os"
8 | "testing"
9 |
10 | "github.com/nianticlabs/modron/src/constants"
11 | )
12 |
13 | func TestListServiceAccounts(t *testing.T) {
14 | ctx := context.Background()
15 | coll, _ := getCollector(ctx, t)
16 | project := constants.GCPProjectsNamePrefix + os.Getenv("PROJECT_ID")
17 | accounts, err := coll.(*GCPCollector).ListServiceAccounts(ctx, project)
18 | if err != nil {
19 | t.Fatalf("ListServiceAccounts failed: %v", err)
20 | }
21 | if len(accounts) == 0 {
22 | t.Fatalf("ListServiceAccounts returned 0 accounts")
23 | }
24 | for _, account := range accounts {
25 | t.Logf("ServiceAccount: %s", account.Name)
26 | if account.IamPolicy != nil && len(account.IamPolicy.Permissions) > 0 {
27 | t.Logf("\tIAM Policy: %v", account.IamPolicy)
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/spanner.go:
--------------------------------------------------------------------------------
1 | package gcpcollector
2 |
3 | import (
4 | "github.com/nianticlabs/modron/src/common"
5 | "github.com/nianticlabs/modron/src/constants"
6 | pb "github.com/nianticlabs/modron/src/proto/generated"
7 |
8 | "golang.org/x/net/context"
9 | )
10 |
11 | func (collector *GCPCollector) ListSpannerDatabases(ctx context.Context, rgName string) (resources []*pb.Resource, err error) {
12 | name := constants.ResourceWithProjectsPrefix(rgName)
13 | dbs, err := collector.api.ListSpannerDatabases(ctx, name)
14 | if err != nil {
15 | return nil, err
16 | }
17 | for _, database := range dbs {
18 | dbResource := &pb.Resource{
19 | // TODO: Collect IAM Policy
20 | Uid: common.GetUUID(uuidGenRetries),
21 | ResourceGroupName: rgName,
22 | Name: database.Name,
23 | Parent: rgName,
24 | Type: &pb.Resource_Database{
25 | Database: &pb.Database{
26 | Type: "spanner",
27 | Address: "spanner.googleapis.com",
28 | AutoResize: true,
29 | // Default on Spanner.
30 | TlsRequired: true,
31 | },
32 | },
33 | }
34 | if database.EncryptionConfig != nil {
35 | dbResource.GetDatabase().Encryption = pb.Database_ENCRYPTION_USER_MANAGED
36 | } else {
37 | dbResource.GetDatabase().Encryption = pb.Database_ENCRYPTION_MANAGED
38 | }
39 |
40 | resources = append(resources, dbResource)
41 | }
42 |
43 | return resources, nil
44 | }
45 |
--------------------------------------------------------------------------------
/src/collector/gcpcollector/vm_instance.go:
--------------------------------------------------------------------------------
1 | package gcpcollector
2 |
3 | import (
4 | "github.com/nianticlabs/modron/src/common"
5 | pb "github.com/nianticlabs/modron/src/proto/generated"
6 |
7 | "golang.org/x/net/context"
8 | )
9 |
10 | func (collector *GCPCollector) ListVMInstances(ctx context.Context, rgName string) (vmInstances []*pb.Resource, err error) {
11 | instances, err := collector.api.ListInstances(ctx, rgName)
12 | if err != nil {
13 | return nil, err
14 | }
15 | for _, instance := range instances {
16 | name := instance.Name
17 | privateIP, publicIP := "", ""
18 | for _, networkInterface := range instance.NetworkInterfaces {
19 | privateIP = networkInterface.NetworkIP
20 | for _, accessConfig := range networkInterface.AccessConfigs {
21 | publicIP = accessConfig.NatIP
22 | }
23 | }
24 | serviceAccountName := ""
25 | for _, sa := range instance.ServiceAccounts {
26 | serviceAccountName = sa.Email
27 | }
28 | vmInstances = append(vmInstances, &pb.Resource{
29 | Uid: common.GetUUID(uuidGenRetries),
30 | ResourceGroupName: rgName,
31 | Name: name,
32 | Parent: rgName,
33 | Type: &pb.Resource_VmInstance{
34 | VmInstance: &pb.VmInstance{
35 | PublicIp: publicIP,
36 | PrivateIp: privateIP,
37 | Identity: serviceAccountName,
38 | },
39 | },
40 | })
41 | }
42 | return vmInstances, nil
43 | }
44 |
--------------------------------------------------------------------------------
/src/collector/testcollector/testcollector.go:
--------------------------------------------------------------------------------
1 | package testcollector
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/nianticlabs/modron/src/model"
7 | pb "github.com/nianticlabs/modron/src/proto/generated"
8 |
9 | "golang.org/x/net/context"
10 | )
11 |
12 | var _ model.Collector = (*TestCollector)(nil)
13 |
14 | var errNotImplemented = fmt.Errorf("not implemented")
15 |
16 | type TestCollector struct{}
17 |
18 | func (t TestCollector) CollectAndStoreAll(_ context.Context, _ string, _ []string, _ []*pb.Resource) error {
19 | return errNotImplemented
20 | }
21 |
22 | func (t TestCollector) ListResourceGroupObservations(_ context.Context, _ string, _ string) ([]*pb.Observation, []error) {
23 | return nil, []error{errNotImplemented}
24 | }
25 |
26 | func (t TestCollector) GetResourceGroupWithIamPolicy(_ context.Context, _ string, _ string) (*pb.Resource, error) {
27 | return nil, errNotImplemented
28 | }
29 |
30 | func (t TestCollector) ListResourceGroups(_ context.Context, _ []string) ([]*pb.Resource, error) {
31 | return nil, errNotImplemented
32 | }
33 |
34 | func (t TestCollector) ListResourceGroupsWithIamPolicies(_ context.Context, _ []string) ([]*pb.Resource, error) {
35 | return nil, errNotImplemented
36 | }
37 |
38 | func (t TestCollector) ListResourceGroupNames(_ context.Context) ([]string, error) {
39 | return nil, errNotImplemented
40 | }
41 |
42 | func (t TestCollector) ListResourceGroupAdmins(_ context.Context) (model.ACLCache, error) {
43 | return model.ACLCache{
44 | "*": {
45 | "projects/modron-test": {},
46 | "projects/super-secret": {},
47 | },
48 | "user@example.com": {
49 | "projects/modron-test": {},
50 | },
51 | }, nil
52 | }
53 |
54 | func (t TestCollector) ListResourceGroupResources(_ context.Context, _ string, _ string) ([]*pb.Resource, []error) {
55 | return nil, []error{errNotImplemented}
56 | }
57 |
--------------------------------------------------------------------------------
/src/constants/context.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | type runnerCtxKey string
4 |
5 | const (
6 | CollectIDKey runnerCtxKey = "collect_id"
7 | ScanIDKey runnerCtxKey = "scan_id"
8 | )
9 |
--------------------------------------------------------------------------------
/src/engine/rules/common.go:
--------------------------------------------------------------------------------
1 | package rules
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 |
7 | "github.com/nianticlabs/modron/src/constants"
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 |
10 | "github.com/sirupsen/logrus"
11 | )
12 |
13 | var log = logrus.StandardLogger().WithField(constants.LogKeyPkg, "rules")
14 |
15 | func getAccountRoles(perm *pb.Permission, account string) (roles []string) {
16 | for _, principal := range perm.Principals {
17 | if strings.HasPrefix(principal, PrincipalDeleted) {
18 | continue
19 | }
20 | s := strings.Split(principal, ":")
21 | if len(s) != 2 { // nolint:mnd
22 | log.Warn("invalid principal in org policy: ", principal)
23 | continue
24 | }
25 | p := strings.Split(principal, ":")[1]
26 | if strings.EqualFold(p, account) {
27 | roles = append(roles, perm.Role)
28 | }
29 | }
30 | return roles
31 | }
32 |
33 | const (
34 | PrincipalDeleted = "deleted:"
35 | )
36 |
37 | // TODO: Add SelfLink and HumanReadableName field to Protobuf and move this logic to the collector.
38 | func getGcpReadableResourceName(resourceName string) string {
39 | if !(strings.Contains(resourceName, "[") && strings.Contains(resourceName, "]")) {
40 | return resourceName
41 | }
42 | m := regexp.MustCompile(`\[.*\]$`)
43 | return m.ReplaceAllLiteralString(resourceName, "")
44 | }
45 |
--------------------------------------------------------------------------------
/src/engine/rules/database_allows_unencrypted_connections_test.go:
--------------------------------------------------------------------------------
1 | package rules
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/nianticlabs/modron/src/model"
7 | pb "github.com/nianticlabs/modron/src/proto/generated"
8 | "github.com/nianticlabs/modron/src/utils"
9 |
10 | "google.golang.org/protobuf/types/known/structpb"
11 | )
12 |
13 | func TestCheckDetectsDatabaseAllowsUnencryptedConnections(t *testing.T) {
14 | databaseNoForceTLS := &pb.Resource{
15 | Name: "database-no-force-tls",
16 | Parent: testProjectName,
17 | ResourceGroupName: testProjectName,
18 | IamPolicy: &pb.IamPolicy{},
19 | Type: &pb.Resource_Database{
20 | Database: &pb.Database{
21 | Type: "cloudsql",
22 | Version: "123",
23 | TlsRequired: false,
24 | },
25 | },
26 | }
27 | resources := []*pb.Resource{
28 | {
29 | Name: testProjectName,
30 | Parent: "",
31 | ResourceGroupName: testProjectName,
32 | IamPolicy: &pb.IamPolicy{},
33 | Type: &pb.Resource_ResourceGroup{
34 | ResourceGroup: &pb.ResourceGroup{},
35 | },
36 | },
37 | databaseNoForceTLS,
38 | {
39 | Name: "database-force-tls",
40 | Parent: testProjectName,
41 | ResourceGroupName: testProjectName,
42 | IamPolicy: &pb.IamPolicy{},
43 | Type: &pb.Resource_Database{
44 | Database: &pb.Database{
45 | Type: "cloudsql",
46 | Version: "123",
47 | TlsRequired: true,
48 | },
49 | },
50 | },
51 | }
52 |
53 | want := []*pb.Observation{
54 | {
55 | Name: DatabaseAllowsUnencryptedConnections,
56 | ResourceRef: utils.GetResourceRef(databaseNoForceTLS),
57 | ObservedValue: structpb.NewBoolValue(false),
58 | ExpectedValue: structpb.NewBoolValue(true),
59 | Remediation: &pb.Remediation{
60 | Description: "Database database-no-force-tls allows for unencrypted connections.",
61 | Recommendation: "Enable the require SSL setting in the database settings to allow only encrypted connections to database-no-force-tls.",
62 | },
63 | Severity: pb.Severity_SEVERITY_MEDIUM,
64 | },
65 | }
66 |
67 | TestRuleRun(t, resources, []model.Rule{NewDatabaseAllowsUnencryptedConnectionsRule()}, want)
68 | }
69 |
--------------------------------------------------------------------------------
/src/engine/rules/kubernetes_vulnerability_scanning_e2e_test.go:
--------------------------------------------------------------------------------
1 | //go:build integration
2 |
3 | package rules
4 |
5 | import (
6 | "testing"
7 |
8 | "github.com/nianticlabs/modron/src/model"
9 | )
10 |
11 | func TestKubernetesVulnerabilityScanningRuleE2E(t *testing.T) {
12 | obs, err := TestE2ERuleRun(t, []model.Rule{NewKubernetesVulnerabilityScanningDisabledRule()})
13 | if err != nil {
14 | t.Fatalf("TestE2ERuleRun unexpected error: %v", err)
15 | }
16 | for _, o := range obs {
17 | t.Logf("Observation: %v", o)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/engine/rules/registry.go:
--------------------------------------------------------------------------------
1 | package rules
2 |
3 | import (
4 | "fmt"
5 | "sync"
6 |
7 | "github.com/nianticlabs/modron/src/model"
8 | )
9 |
10 | var rules sync.Map
11 |
12 | func AddRule(r model.Rule) {
13 | ruleName := r.Info().Name
14 | _, ok := rules.Load(ruleName)
15 | if ok {
16 | panic(fmt.Sprintf("rule %q already exists", ruleName))
17 | }
18 | rules.Store(r.Info().Name, r)
19 | }
20 |
21 | func GetRule(name string) (model.Rule, error) {
22 | if rule, ok := rules.Load(name); ok {
23 | return rule.(model.Rule), nil
24 | }
25 | return nil, fmt.Errorf("could not find rule %q", name)
26 | }
27 |
28 | func GetRules() []model.Rule {
29 | var rulesSnapshot []model.Rule
30 | rules.Range(func(_, rule any) bool {
31 | rulesSnapshot = append(rulesSnapshot, rule.(model.Rule))
32 | return true
33 | })
34 |
35 | return rulesSnapshot
36 | }
37 |
--------------------------------------------------------------------------------
/src/engine/rules/registry_test.go:
--------------------------------------------------------------------------------
1 | package rules
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "google.golang.org/protobuf/proto"
8 |
9 | "github.com/nianticlabs/modron/src/model"
10 | pb "github.com/nianticlabs/modron/src/proto/generated"
11 | )
12 |
13 | type TestRule struct {
14 | info model.RuleInfo
15 | }
16 |
17 | func NewTestRule(name string) *TestRule {
18 | return &TestRule{
19 | info: model.RuleInfo{
20 | Name: name,
21 | AcceptedResourceTypes: []proto.Message{},
22 | },
23 | }
24 | }
25 |
26 | func (r *TestRule) Check(context.Context, model.Engine, *pb.Resource) ([]*pb.Observation, []error) {
27 | return []*pb.Observation{{}}, nil
28 | }
29 |
30 | func (r *TestRule) Info() *model.RuleInfo {
31 | return &r.info
32 | }
33 |
34 | func TestAddAndGetRule(t *testing.T) {
35 | want := NewTestRule("TEST_RULE_3")
36 | AddRule(want)
37 |
38 | got, err := GetRule("TEST_RULE_3")
39 | if err != nil {
40 | t.Errorf(`GetRule unexpected error "%v"`, err)
41 | }
42 | if got != want {
43 | t.Errorf(`GetRule unexpected diff (-want, +got): "-%v", "+%v"`, want, got)
44 | }
45 | }
46 |
47 | // TODO: Make test state-independent (i.e., irrespective of the presence of rules in the registry).
48 | func TestGetRules(t *testing.T) {
49 | rulesToAdd := []model.Rule{NewTestRule("TEST_RULE1"), NewTestRule("TEST_RULE2")}
50 | for _, rule := range rulesToAdd {
51 | AddRule(rule)
52 | }
53 |
54 | rules := GetRules()
55 |
56 | for _, addedRule := range rulesToAdd {
57 | found := false
58 |
59 | for _, rule := range rules {
60 | if rule == addedRule {
61 | found = true
62 | break
63 | }
64 | }
65 | if !found {
66 | t.Errorf(`GetRules() does not contain rule "%v"`, addedRule.Info().Name)
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/engine/rules/testing_e2e.go:
--------------------------------------------------------------------------------
1 | package rules
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "os"
8 | "testing"
9 |
10 | "github.com/sirupsen/logrus"
11 |
12 | "github.com/nianticlabs/modron/src/collector/gcpcollector"
13 | "github.com/nianticlabs/modron/src/engine"
14 | "github.com/nianticlabs/modron/src/model"
15 | pb "github.com/nianticlabs/modron/src/proto/generated"
16 | "github.com/nianticlabs/modron/src/risk"
17 | "github.com/nianticlabs/modron/src/storage/memstorage"
18 | )
19 |
20 | func TestE2ERuleRun(t *testing.T, rules []model.Rule) ([]*pb.Observation, error) {
21 | t.Helper()
22 | logrus.StandardLogger().SetLevel(logrus.DebugLevel)
23 | projectID := os.Getenv("PROJECT_ID")
24 | if projectID == "" {
25 | t.Skip("PROJECT_ID is not set")
26 | }
27 | orgID := os.Getenv("ORG_ID")
28 | if orgID == "" {
29 | t.Skip("ORG_ID is not set")
30 | }
31 | orgSuffix := os.Getenv("ORG_SUFFIX")
32 | if orgSuffix == "" {
33 | t.Skip("ORG_SUFFIX is not set")
34 | }
35 | tagConfig := risk.TagConfig{
36 | Environment: "111111111111/environment",
37 | EmployeeData: "111111111111/employee_data",
38 | CustomerData: "111111111111/customer_data",
39 | }
40 | qualifiedProjectID := "projects/" + projectID
41 | ctx := context.Background()
42 | storage := memstorage.New()
43 | collector, err := gcpcollector.New(ctx, storage, orgID, orgSuffix, []string{}, tagConfig, []string{})
44 | if err != nil {
45 | t.Fatalf("NewCollector unexpected error: %v", err)
46 | }
47 |
48 | if err := collector.CollectAndStoreAll(ctx, "test-collect", []string{qualifiedProjectID}, nil); err != nil {
49 | t.Fatalf("collectAndStoreResources unexpected error: %v", err)
50 | }
51 |
52 | e, err := engine.New(storage, rules, map[string]json.RawMessage{}, []string{}, tagConfig)
53 | if err != nil {
54 | t.Fatalf("NewEngine unexpected error: %v", err)
55 | }
56 | obs, errArr := e.CheckRules(ctx, "unit-test-scan", "", []string{qualifiedProjectID}, nil)
57 | if errArr != nil {
58 | return nil, errors.Join(errArr...)
59 | }
60 | return obs, nil
61 | }
62 |
--------------------------------------------------------------------------------
/src/engine/runner_integration_test.go:
--------------------------------------------------------------------------------
1 | //go:build integration
2 |
3 | package engine_test
4 |
5 | import (
6 | "context"
7 | "encoding/json"
8 | "errors"
9 | "os"
10 | "testing"
11 |
12 | "github.com/google/uuid"
13 | "github.com/sirupsen/logrus"
14 |
15 | "github.com/nianticlabs/modron/src/collector/gcpcollector"
16 | "github.com/nianticlabs/modron/src/engine"
17 | "github.com/nianticlabs/modron/src/engine/rules"
18 | "github.com/nianticlabs/modron/src/model"
19 | "github.com/nianticlabs/modron/src/risk"
20 | "github.com/nianticlabs/modron/src/storage/memstorage"
21 | )
22 |
23 | func TestCrossEnvironmentRuleIntegration(t *testing.T) {
24 | rgNames := []string{
25 | "projects/modron-dev",
26 | }
27 | ctx := context.Background()
28 | st := memstorage.New()
29 | collectID := uuid.NewString()
30 | scanID := uuid.NewString()
31 |
32 | logrus.StandardLogger().SetLevel(logrus.DebugLevel)
33 | logrus.StandardLogger().SetFormatter(&logrus.TextFormatter{
34 | ForceColors: true,
35 | })
36 |
37 | orgID := os.Getenv("ORG_ID")
38 | orgSuffix := os.Getenv("ORG_SUFFIX")
39 | if orgID == "" || orgSuffix == "" {
40 | t.Fatalf("ORG_ID and ORG_SUFFIX are required for this test")
41 | }
42 | tagConfig := risk.TagConfig{
43 | Environment: "111111111111/environment",
44 | EmployeeData: "111111111111/employee_data",
45 | CustomerData: "111111111111/customer_data",
46 | }
47 |
48 | // Collect resources
49 | c, err := gcpcollector.New(ctx, st, orgID, orgSuffix, []string{}, tagConfig, []string{})
50 | if err != nil {
51 | t.Fatalf("failed to create collector: %v", err)
52 | }
53 | if err := c.CollectAndStoreAll(ctx, collectID, rgNames, nil); err != nil {
54 | t.Fatalf("failed to collect resources: %v", err)
55 | }
56 |
57 | e, _ := engine.New(st, []model.Rule{
58 | rules.NewCrossEnvironmentPermissionsRule(),
59 | }, map[string]json.RawMessage{}, []string{}, tagConfig)
60 | obs, errArr := e.CheckRules(ctx, scanID, collectID, rgNames, nil)
61 | err = errors.Join(errArr...)
62 | if err != nil {
63 | t.Fatalf("failed to check rules: %v", err)
64 | }
65 | t.Logf("Observations: %v", obs)
66 | }
67 |
--------------------------------------------------------------------------------
/src/log.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | "time"
7 |
8 | "github.com/sirupsen/logrus"
9 | )
10 |
11 | type LogFormat string
12 |
13 | const (
14 | LogFormatJSON LogFormat = "json"
15 | LogFormatText LogFormat = "text"
16 | )
17 |
18 | // setLogLevel sets the logrus log level
19 | func setLogLevel() {
20 | switch strings.ToLower(args.LogLevel) {
21 | case "trace":
22 | log.SetLevel(logrus.TraceLevel)
23 | case "debug":
24 | log.SetLevel(logrus.DebugLevel)
25 | case "info":
26 | log.SetLevel(logrus.InfoLevel)
27 | case "warning":
28 | log.SetLevel(logrus.WarnLevel)
29 | case "error":
30 | log.SetLevel(logrus.ErrorLevel)
31 | }
32 | }
33 |
34 | type gcpFormatter struct{}
35 |
36 | func (g gcpFormatter) Format(entry *logrus.Entry) ([]byte, error) {
37 | output := map[string]any{}
38 | output["severity"] = toGcpSeverity(entry.Level)
39 | output["message"] = entry.Message
40 | output["timestamp"] = entry.Time.Format(time.RFC3339Nano)
41 | if len(entry.Data) > 0 {
42 | output["labels"] = entry.Data
43 | }
44 |
45 | b, err := json.Marshal(output)
46 | if err != nil {
47 | return nil, err
48 | }
49 | b = append(b, '\n')
50 | return b, nil
51 | }
52 |
53 | func toGcpSeverity(level logrus.Level) string {
54 | switch level {
55 | case logrus.TraceLevel, logrus.DebugLevel:
56 | return "DEBUG"
57 | case logrus.InfoLevel:
58 | return "INFO"
59 | case logrus.WarnLevel:
60 | return "WARNING"
61 | case logrus.ErrorLevel:
62 | return "ERROR"
63 | case logrus.FatalLevel, logrus.PanicLevel:
64 | return "CRITICAL"
65 | default:
66 | return "DEFAULT"
67 | }
68 | }
69 |
70 | var gcpFormat = &gcpFormatter{}
71 | var textFormatter = &logrus.TextFormatter{}
72 |
73 | // setLogFormat sets the format of the logs
74 | func setLogFormat() {
75 | var formatter logrus.Formatter
76 | invalidFormatter := false
77 | switch args.LogFormat {
78 | case LogFormatJSON:
79 | formatter = gcpFormat
80 | case LogFormatText:
81 | formatter = textFormatter
82 | default:
83 | formatter = textFormatter
84 | invalidFormatter = true
85 | }
86 |
87 | logrus.SetFormatter(formatter)
88 | if invalidFormatter {
89 | log.Errorf("invalid log format, using %s", LogFormatText)
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/lognotifier/lognotifier.go:
--------------------------------------------------------------------------------
1 | package lognotifier
2 |
3 | import (
4 | "context"
5 | "errors"
6 |
7 | "github.com/nianticlabs/modron/src/constants"
8 | "github.com/nianticlabs/modron/src/model"
9 |
10 | "github.com/sirupsen/logrus"
11 | )
12 |
13 | func New() model.NotificationService {
14 | return &LogNotifier{}
15 | }
16 |
17 | var log = logrus.StandardLogger().WithField(constants.LogKeyPkg, "lognotifier")
18 |
19 | type LogNotifier struct {
20 | exceptions []model.Exception
21 | }
22 |
23 | func (ln *LogNotifier) BatchCreateNotifications(ctx context.Context, notifications []model.Notification) ([]model.Notification, error) {
24 | var resultNotifications []model.Notification
25 | var errArr []error
26 | for _, v := range notifications {
27 | notif, err := ln.CreateNotification(ctx, v)
28 | if err != nil {
29 | errArr = append(errArr, err)
30 | continue
31 | }
32 | resultNotifications = append(resultNotifications, notif)
33 | }
34 | return resultNotifications, errors.Join(errArr...)
35 | }
36 |
37 | func (ln *LogNotifier) CreateNotification(_ context.Context, notification model.Notification) (model.Notification, error) {
38 | log.Infof("create notification: %+v", notification)
39 | return notification, nil
40 | }
41 |
42 | func (ln *LogNotifier) GetException(_ context.Context, uuid string) (model.Exception, error) {
43 | log.Infof("get exception called with %q", uuid)
44 | return model.Exception{UUID: uuid}, nil
45 | }
46 |
47 | func (ln *LogNotifier) CreateException(_ context.Context, exception model.Exception) (model.Exception, error) {
48 | log.Infof("create exception %+v", exception)
49 | ln.exceptions = append(ln.exceptions, exception)
50 | return exception, nil
51 | }
52 |
53 | func (ln *LogNotifier) UpdateException(_ context.Context, exception model.Exception) (model.Exception, error) {
54 | log.Infof("update exception: %+v", exception)
55 | return exception, nil
56 | }
57 |
58 | func (ln *LogNotifier) DeleteException(_ context.Context, id string) error {
59 | log.Infof("delete exception %q", id)
60 | return nil
61 | }
62 |
63 | func (ln *LogNotifier) ListExceptions(_ context.Context, userEmail string, _ int32, _ string) ([]model.Exception, error) {
64 | log.Infof("list exceptions for user %q", userEmail)
65 | return ln.exceptions, nil
66 | }
67 |
--------------------------------------------------------------------------------
/src/metric/keys.go:
--------------------------------------------------------------------------------
1 | package metric
2 |
3 | const (
4 | KeyCategory = "category"
5 | KeyCount = "count"
6 | KeyMethod = "method"
7 | KeyOffendingPackage = "offending_package"
8 | KeyPath = "path"
9 | KeyRecipient = "recipient"
10 | KeyRule = "rule"
11 | KeySeverity = "severity"
12 | KeyStatus = "status"
13 | )
14 |
--------------------------------------------------------------------------------
/src/metric/status.go:
--------------------------------------------------------------------------------
1 | package metric
2 |
3 | type Status = string
4 |
5 | const (
6 | StatusCancelled Status = "cancelled"
7 | StatusCompleted Status = "completed"
8 | StatusError Status = "error"
9 | StatusSuccess Status = "success"
10 | )
11 |
--------------------------------------------------------------------------------
/src/model/acl.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "golang.org/x/net/context"
4 |
5 | // ACLCache is a map of users to a map of resource names: {"user@example.com": {"projects/xyz": {}}}
6 | // the reason why the last part is a struct{} is that we don't care about the value, we only care about the key
7 | // and a struct{} is the smallest value we can use.
8 | type ACLCache map[string]map[string]struct{}
9 |
10 | type Checker interface {
11 | GetACL() ACLCache
12 | GetValidatedUser(ctx context.Context) (string, error)
13 | ListResourceGroupNamesOwned(ctx context.Context) (map[string]struct{}, error)
14 | }
15 |
--------------------------------------------------------------------------------
/src/model/collector.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "golang.org/x/net/context"
5 |
6 | pb "github.com/nianticlabs/modron/src/proto/generated"
7 | )
8 |
9 | type Collector interface {
10 | CollectAndStoreAll(ctx context.Context, collectID string, resourceGroupNames []string, preCollectedRgs []*pb.Resource) error
11 |
12 | GetResourceGroupWithIamPolicy(ctx context.Context, collectID string, rgName string) (*pb.Resource, error)
13 | ListResourceGroups(ctx context.Context, rgNames []string) ([]*pb.Resource, error)
14 | ListResourceGroupsWithIamPolicies(ctx context.Context, rgNames []string) ([]*pb.Resource, error)
15 | ListResourceGroupNames(ctx context.Context) ([]string, error)
16 | ListResourceGroupAdmins(ctx context.Context) (ACLCache, error)
17 | ListResourceGroupResources(ctx context.Context, collectID string, rgName string) ([]*pb.Resource, []error)
18 | ListResourceGroupObservations(ctx context.Context, collectID string, rgName string) ([]*pb.Observation, []error)
19 | }
20 |
--------------------------------------------------------------------------------
/src/model/engine.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 |
7 | pb "github.com/nianticlabs/modron/src/proto/generated"
8 | "github.com/nianticlabs/modron/src/risk"
9 | )
10 |
11 | type Engine interface {
12 | GetChildren(ctx context.Context, parent string) ([]*pb.Resource, error)
13 | GetResource(ctx context.Context, resourceName string) (*pb.Resource, error)
14 | GetHierarchy(ctx context.Context, collectionID string) (map[string]*pb.RecursiveResource, error)
15 | GetTagConfig() risk.TagConfig
16 |
17 | CheckRules(ctx context.Context, scanID string, collectID string, groups []string, preCollectedRGs []*pb.Resource) ([]*pb.Observation, []error)
18 | GetRuleConfig(ctx context.Context, ruleName string) (json.RawMessage, error)
19 | GetRules() []Rule
20 | }
21 |
--------------------------------------------------------------------------------
/src/model/model.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "context"
5 |
6 | "google.golang.org/protobuf/proto"
7 |
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 | )
10 |
11 | // Rule is the interface that is implemented by the rules.
12 | // A `Rule` takes a resource, checks its observed values against an expected reference value,
13 | // and creates an observation if it identifies a discrepancy, which may include a remediation for resolving it.
14 | type Rule interface {
15 | // Check Performs a rule-dependent check on a resource and, in case it detects an anomaly,
16 | // returns a list of observations. The method MUST return nil in case either it did
17 | // not create any observations or detect any errors.
18 | Check(ctx context.Context, engine Engine, rsrc *pb.Resource) (obs []*pb.Observation, errs []error)
19 | // Info returns the associated RuleInfo data.
20 | Info() *RuleInfo
21 | }
22 |
23 | type RuleInfo struct {
24 | // Human-readable name of the rule, e.g., "EXPOSED_INFRASTRUCTURE_WITH_ADMIN_PRIVILEGES".
25 | Name string
26 | // Types of resource this rule accepts as an input to `Check`. This helps the rule engine
27 | // fetch in advance all the resources the rule needs to perform check(s) against.
28 | AcceptedResourceTypes []proto.Message
29 | }
30 |
31 | type RuleEngine interface {
32 | CheckRules(
33 | ctx context.Context,
34 | scanID string,
35 | collectID string,
36 | resourceGroups []string,
37 | preCollectedRgs []*pb.Resource,
38 | ) (obs []*pb.Observation, errs []error)
39 | GetRules() []Rule
40 | }
41 |
--------------------------------------------------------------------------------
/src/model/stateManager.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | pb "github.com/nianticlabs/modron/src/proto/generated"
5 | )
6 |
7 | type StateManager interface {
8 | GetCollectState(collectID string) pb.RequestStatus
9 | GetScanState(scanID string) pb.RequestStatus
10 |
11 | AddScan(scanID string, resourceGroupNames []string) []string
12 | EndScan(scanID string, resourceGroupNames []string)
13 |
14 | AddCollect(collectID string, resourceGroupNames []string) []string
15 | EndCollect(collectID string, resourceGroupNames []string)
16 | }
17 |
--------------------------------------------------------------------------------
/src/model/storage.go:
--------------------------------------------------------------------------------
1 | // Package model is a shared set of models needed
2 | package model
3 |
4 | import (
5 | "context"
6 | "time"
7 |
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 | )
10 |
11 | type StorageFilter struct {
12 | Limit int
13 | ResourceNames []string
14 | ResourceTypes []string
15 | ResourceGroupNames []string
16 | ResourceIDs []string
17 | ParentNames []string
18 | OperationID string
19 | StartTime time.Time
20 | TimeOffset time.Duration
21 | }
22 |
23 | type Storage interface {
24 | BatchCreateResources(ctx context.Context, resource []*pb.Resource) ([]*pb.Resource, error)
25 | ListResources(ctx context.Context, filter StorageFilter) ([]*pb.Resource, error)
26 | BatchCreateObservations(ctx context.Context, observations []*pb.Observation) ([]*pb.Observation, error)
27 | ListObservations(ctx context.Context, filter StorageFilter) ([]*pb.Observation, error)
28 | GetChildrenOfResource(ctx context.Context, collectID string, parentResourceName string, resourceType *string) (map[string]*pb.RecursiveResource, error)
29 |
30 | AddOperationLog(ctx context.Context, ops []*pb.Operation) error
31 | FlushOpsLog(ctx context.Context) error
32 | PurgeIncompleteOperations(ctx context.Context) error
33 | }
34 |
--------------------------------------------------------------------------------
/src/nagatha/convert.go:
--------------------------------------------------------------------------------
1 | package nagatha
2 |
3 | import (
4 | "github.com/nianticlabs/modron/src/model"
5 | "github.com/nianticlabs/modron/src/proto/generated/nagatha"
6 |
7 | "google.golang.org/protobuf/types/known/timestamppb"
8 | )
9 |
10 | func exceptionModelFromNagathaProto(ex *nagatha.Exception) model.Exception {
11 | return model.Exception{
12 | UUID: ex.Uuid,
13 | SourceSystem: ex.SourceSystem,
14 | UserEmail: ex.UserEmail,
15 | NotificationName: ex.NotificationName,
16 | Justification: ex.Justification,
17 | CreatedOn: ex.CreatedOnTime.AsTime(),
18 | ValidUntil: ex.ValidUntilTime.AsTime(),
19 | }
20 | }
21 |
22 | func exceptionNagathaProtoFromModel(ex model.Exception) *nagatha.Exception {
23 | return &nagatha.Exception{
24 | Uuid: ex.UUID,
25 | SourceSystem: ex.SourceSystem,
26 | UserEmail: ex.UserEmail,
27 | NotificationName: ex.NotificationName,
28 | Justification: ex.Justification,
29 | CreatedOnTime: timestamppb.New(ex.CreatedOn),
30 | ValidUntilTime: timestamppb.New(ex.ValidUntil),
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/nagatha/notification.go:
--------------------------------------------------------------------------------
1 | package nagatha
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/nianticlabs/modron/src/model"
7 | pb "github.com/nianticlabs/modron/src/proto/generated"
8 | "github.com/nianticlabs/modron/src/proto/generated/nagatha"
9 | )
10 |
11 | func NotificationFromObservation(contact string, interval time.Duration, obs *pb.Observation) model.Notification {
12 | return model.Notification{
13 | SourceSystem: "modron",
14 | Name: obs.Name,
15 | Recipient: contact,
16 | Content: formatNotificationContent(obs),
17 | Interval: interval,
18 | }
19 | }
20 |
21 | func notificationFromProto(p *nagatha.Notification) model.Notification {
22 | return model.Notification{
23 | UUID: p.Uuid,
24 | SourceSystem: p.SourceSystem,
25 | Name: p.Name,
26 | Recipient: p.Recipient,
27 | Content: p.Content,
28 | CreatedOn: p.CreatedOn.AsTime(),
29 | SentOn: p.SentOn.AsTime(),
30 | Interval: p.Interval.AsDuration(),
31 | }
32 | }
33 |
34 | func formatNotificationContent(obs *pb.Observation) string {
35 | var out string
36 | out += obs.Remediation.Description + "\n\n"
37 | out += obs.Remediation.Recommendation + " \n \n"
38 | return out
39 | }
40 |
--------------------------------------------------------------------------------
/src/nagatha/notification_test.go:
--------------------------------------------------------------------------------
1 | package nagatha_test
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/gogo/protobuf/proto"
8 | "github.com/google/go-cmp/cmp"
9 | "google.golang.org/protobuf/types/known/timestamppb"
10 |
11 | "github.com/nianticlabs/modron/src/model"
12 | "github.com/nianticlabs/modron/src/nagatha"
13 | pb "github.com/nianticlabs/modron/src/proto/generated"
14 | )
15 |
16 | func TestNotificationFromObservation(t *testing.T) {
17 | pbObs := pb.Observation{
18 | Uid: "47fc94f3-b6e9-4ae5-b719-c8dd2157744b",
19 | ScanUid: proto.String("2f86b47d-a386-4f4e-88a1-786f2a572ad8"),
20 | Timestamp: timestamppb.New(time.Now()),
21 | ResourceRef: &pb.ResourceRef{
22 | Uid: proto.String("79bc4bd2-a454-4837-8a09-0d769cb36d0f"),
23 | GroupName: "projects/some-project",
24 | },
25 | Name: "DATABASE_ALLOWS_UNENCRYPTED_CONNECTIONS",
26 | Remediation: &pb.Remediation{
27 | Description: "Database example-psql allows for unencrypted connections.",
28 | Recommendation: "Enable the require SSL setting in the database settings to allow only encrypted connections to example-psql.",
29 | },
30 | }
31 |
32 | want := model.Notification{
33 | SourceSystem: "modron",
34 | Name: "DATABASE_ALLOWS_UNENCRYPTED_CONNECTIONS",
35 | Content: "Database example-psql allows for unencrypted connections.\n\nEnable the require SSL setting in the database settings to allow only encrypted connections to example-psql. \n \n",
36 | Recipient: "test@example.com",
37 | Interval: 24 * time.Hour,
38 | }
39 | got := nagatha.NotificationFromObservation("test@example.com", 24*time.Hour, &pbObs)
40 | if diff := cmp.Diff(&got, &want); diff != "" {
41 | t.Errorf("NotificationFromObservation() mismatch (-got +want):\n%s", diff)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/proto/.gitignore:
--------------------------------------------------------------------------------
1 | generated/*
2 | !generated/go.mod
3 | !generated/go.sum
4 |
--------------------------------------------------------------------------------
/src/proto/generated/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/nianticlabs/modron/src/proto/generated
2 |
3 | go 1.23.2
4 |
5 | require (
6 | cloud.google.com/go/longrunning v0.6.1
7 | google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1
8 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38
9 | google.golang.org/grpc v1.67.1
10 | google.golang.org/protobuf v1.35.1
11 | k8s.io/api v0.31.2
12 | k8s.io/apimachinery v0.31.2
13 | )
14 |
15 | require (
16 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect
17 | github.com/go-logr/logr v1.4.2 // indirect
18 | github.com/gogo/protobuf v1.3.2 // indirect
19 | github.com/google/gofuzz v1.2.0 // indirect
20 | github.com/json-iterator/go v1.1.12 // indirect
21 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
22 | github.com/modern-go/reflect2 v1.0.2 // indirect
23 | github.com/x448/float16 v0.8.4 // indirect
24 | golang.org/x/net v0.30.0 // indirect
25 | golang.org/x/sys v0.26.0 // indirect
26 | golang.org/x/text v0.19.0 // indirect
27 | gopkg.in/inf.v0 v0.9.1 // indirect
28 | gopkg.in/yaml.v2 v2.4.0 // indirect
29 | k8s.io/klog/v2 v2.130.1 // indirect
30 | k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 // indirect
31 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
32 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
33 | )
34 |
--------------------------------------------------------------------------------
/src/proto/notification.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 |
3 | import "google/protobuf/empty.proto";
4 | import "google/protobuf/field_mask.proto";
5 | import "google/protobuf/timestamp.proto";
6 |
7 | option go_package = "./";
8 |
9 | message NotificationException {
10 | string uuid = 1;
11 | string source_system = 2;
12 | string user_email = 3;
13 | string notification_name = 4;
14 | string justification = 5;
15 | google.protobuf.Timestamp created_on_time = 6;
16 | google.protobuf.Timestamp valid_until_time = 7;
17 | }
18 |
19 | service NotificationService {
20 | rpc GetNotificationException(GetNotificationExceptionRequest) returns (NotificationException);
21 | rpc CreateNotificationException(CreateNotificationExceptionRequest)returns (NotificationException);
22 | rpc UpdateNotificationException(UpdateNotificationExceptionRequest) returns (NotificationException);
23 | rpc DeleteNotificationException(DeleteNotificationExceptionRequest) returns (google.protobuf.Empty);
24 | rpc ListNotificationExceptions(ListNotificationExceptionsRequest) returns (ListNotificationExceptionsResponse);
25 |
26 | }
27 | message GetNotificationExceptionRequest {
28 | string uuid = 1;
29 | }
30 |
31 | message CreateNotificationExceptionRequest {
32 | NotificationException exception = 1;
33 | }
34 |
35 | message UpdateNotificationExceptionRequest {
36 | NotificationException exception = 1;
37 |
38 | google.protobuf.FieldMask update_mask = 2;
39 | }
40 |
41 | message DeleteNotificationExceptionRequest {
42 | string uuid = 1;
43 | }
44 |
45 | message ListNotificationExceptionsRequest {
46 | string user_email = 1;
47 |
48 | int32 page_size = 2;
49 |
50 | string page_token = 3;
51 | }
52 |
53 | message ListNotificationExceptionsResponse {
54 | repeated NotificationException exceptions = 1;
55 |
56 | string next_page_token = 2;
57 | }
58 |
--------------------------------------------------------------------------------
/src/service/mock_notifier_test.go:
--------------------------------------------------------------------------------
1 | package service_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "sync"
7 |
8 | "github.com/nianticlabs/modron/src/model"
9 | )
10 |
11 | type mockNotifier struct {
12 | notificationLock sync.Mutex
13 | notifications []model.Notification
14 | }
15 |
16 | func (m *mockNotifier) BatchCreateNotifications(ctx context.Context, notifications []model.Notification) ([]model.Notification, error) {
17 | var createdNotifications []model.Notification
18 | var errArr []error
19 | for _, n := range notifications {
20 | created, err := m.CreateNotification(ctx, n)
21 | if err != nil {
22 | errArr = append(errArr, err)
23 | continue
24 | }
25 | createdNotifications = append(createdNotifications, created)
26 | }
27 | return createdNotifications, errors.Join(errArr...)
28 | }
29 |
30 | func (m *mockNotifier) CreateNotification(_ context.Context, notification model.Notification) (model.Notification, error) {
31 | m.notificationLock.Lock()
32 | defer m.notificationLock.Unlock()
33 | m.notifications = append(m.notifications, notification)
34 | return notification, nil
35 | }
36 |
37 | func (m *mockNotifier) GetException(context.Context, string) (model.Exception, error) {
38 | panic("implement me")
39 | }
40 |
41 | func (m *mockNotifier) CreateException(context.Context, model.Exception) (model.Exception, error) {
42 | panic("implement me")
43 | }
44 |
45 | func (m *mockNotifier) UpdateException(context.Context, model.Exception) (model.Exception, error) {
46 | panic("implement me")
47 | }
48 |
49 | func (m *mockNotifier) DeleteException(context.Context, string) error {
50 | panic("implement me")
51 | }
52 |
53 | func (m *mockNotifier) ListExceptions(context.Context, string, int32, string) ([]model.Exception, error) {
54 | panic("implement me")
55 | }
56 |
57 | var _ model.NotificationService = (*mockNotifier)(nil)
58 |
59 | func newMockNotifier() *mockNotifier {
60 | return &mockNotifier{}
61 | }
62 |
63 | func (m *mockNotifier) getNotifications() []model.Notification {
64 | // Clone the notifications
65 | m.notificationLock.Lock()
66 | defer m.notificationLock.Unlock()
67 | notifications := make([]model.Notification, len(m.notifications))
68 | copy(notifications, m.notifications)
69 | return notifications
70 | }
71 |
--------------------------------------------------------------------------------
/src/storage/gormstorage/gorm_integration_test.go:
--------------------------------------------------------------------------------
1 | //go:build integration
2 |
3 | package gormstorage
4 |
5 | import (
6 | "context"
7 | "testing"
8 | "time"
9 |
10 | _ "github.com/lib/pq"
11 | "github.com/testcontainers/testcontainers-go"
12 | "github.com/testcontainers/testcontainers-go/modules/postgres"
13 | "github.com/testcontainers/testcontainers-go/wait"
14 |
15 | "github.com/nianticlabs/modron/src/model"
16 | "github.com/nianticlabs/modron/src/storage/test"
17 | )
18 |
19 | func getPostgresDB(ctx context.Context, t *testing.T) (*postgres.PostgresContainer, error) {
20 | t.Helper()
21 | return postgres.Run(ctx, "postgres:16-alpine",
22 | postgres.WithDatabase("modron"),
23 | testcontainers.WithLogger(testcontainers.TestLogger(t)),
24 | testcontainers.WithWaitStrategy(
25 | wait.ForAll(
26 | wait.ForLog("database system is ready to accept connections").
27 | WithOccurrence(2).
28 | WithStartupTimeout(6*time.Second),
29 | wait.ForListeningPort("5432"),
30 | ),
31 | ),
32 | testcontainers.WithHostPortAccess(5432),
33 | )
34 | }
35 |
36 | func newPostgresTestDb(ctx context.Context, t *testing.T) model.Storage {
37 | t.Helper()
38 | pgDb, err := getPostgresDB(ctx, t)
39 | if err != nil {
40 | t.Fatalf("unable to create postgres container: %v", err)
41 | }
42 | connStr, err := pgDb.ConnectionString(ctx)
43 | if err != nil {
44 | t.Fatalf("unable to get connection string: %v", err)
45 | }
46 | st, err := NewPostgres(Config{
47 | BatchSize: 10,
48 | }, connStr)
49 | if err != nil {
50 | t.Fatalf("failed to create storage: %v", err)
51 | }
52 | return st
53 | }
54 |
55 | func TestPostgresStorageResource(t *testing.T) {
56 | test.StorageResource(t, newPostgresTestDb(context.Background(), t))
57 | }
58 |
59 | func TestPostgresStorageObservation(t *testing.T) {
60 | test.StorageObservation(t, newPostgresTestDb(context.Background(), t))
61 | }
62 |
63 | func TestPostgresStorageListObservationsActive(t *testing.T) {
64 | test.StorageListObservations2(t, newPostgresTestDb(context.Background(), t))
65 | }
66 |
--------------------------------------------------------------------------------
/src/storage/gormstorage/gorm_test.go:
--------------------------------------------------------------------------------
1 | package gormstorage
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/nianticlabs/modron/src/model"
7 | "github.com/nianticlabs/modron/src/storage/test"
8 | storageutils "github.com/nianticlabs/modron/src/storage/utils"
9 | )
10 |
11 | func newTestDb(t *testing.T) model.Storage {
12 | st, err := NewSQLite(Config{
13 | BatchSize: 100,
14 | LogAllQueries: true,
15 | }, storageutils.GetSqliteMemoryDbPath())
16 | if err != nil {
17 | t.Fatalf("failed to create storage: %v", err)
18 | }
19 | return st
20 | }
21 |
22 | func TestStorageResource(t *testing.T) {
23 | test.StorageResource(t, newTestDb(t))
24 | }
25 |
26 | func TestStorageObservation(t *testing.T) {
27 | test.StorageObservation(t, newTestDb(t))
28 | }
29 |
30 | func TestStorageListObservationsActive(t *testing.T) {
31 | test.StorageListObservations2(t, newTestDb(t))
32 | }
33 |
--------------------------------------------------------------------------------
/src/storage/gormstorage/impact.go:
--------------------------------------------------------------------------------
1 | package gormstorage
2 |
3 | import (
4 | "database/sql"
5 | "database/sql/driver"
6 | "fmt"
7 |
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 | )
10 |
11 | type Impact pb.Impact
12 |
13 | func (i *Impact) Scan(src any) error {
14 | str, ok := src.(string)
15 | if !ok {
16 | return fmt.Errorf("expected string, got %T", src)
17 | }
18 | v, ok := pb.Impact_value[str]
19 | if !ok {
20 | return fmt.Errorf("invalid Impact: %q", str)
21 | }
22 | *i = Impact(v)
23 | return nil
24 | }
25 |
26 | func (i Impact) Value() (driver.Value, error) {
27 | return pb.Impact_name[int32(i)], nil
28 | }
29 |
30 | var _ sql.Scanner = (*Impact)(nil)
31 | var _ driver.Valuer = (*Impact)(nil)
32 |
--------------------------------------------------------------------------------
/src/storage/gormstorage/observation_category.go:
--------------------------------------------------------------------------------
1 | package gormstorage
2 |
3 | import (
4 | "database/sql"
5 | "database/sql/driver"
6 | "fmt"
7 |
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 | )
10 |
11 | type ObservationCategory pb.Observation_Category
12 |
13 | func (o ObservationCategory) Value() (driver.Value, error) {
14 | return pb.Observation_Category_name[int32(o)], nil
15 | }
16 |
17 | func (o *ObservationCategory) Scan(src any) error {
18 | str, ok := src.(string)
19 | if !ok {
20 | return fmt.Errorf("expected string, got %T", src)
21 | }
22 | v, ok := pb.Observation_Category_value[str]
23 | if !ok {
24 | return fmt.Errorf("invalid ObservationCategory: %q", str)
25 | }
26 | *o = ObservationCategory(v)
27 | return nil
28 | }
29 |
30 | var _ sql.Scanner = (*ObservationCategory)(nil)
31 | var _ driver.Valuer = (*ObservationCategory)(nil)
32 |
--------------------------------------------------------------------------------
/src/storage/gormstorage/observation_source.go:
--------------------------------------------------------------------------------
1 | package gormstorage
2 |
3 | import (
4 | "database/sql"
5 | "database/sql/driver"
6 | "fmt"
7 |
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 | )
10 |
11 | type ObservationSource pb.Observation_Source
12 |
13 | func (o ObservationSource) Value() (driver.Value, error) {
14 | return pb.Observation_Source_name[int32(o)], nil
15 | }
16 |
17 | func (o *ObservationSource) Scan(src any) error {
18 | str, ok := src.(string)
19 | if !ok {
20 | return fmt.Errorf("expected string, got %T", src)
21 | }
22 | v, ok := pb.Observation_Source_value[str]
23 | if !ok {
24 | return fmt.Errorf("invalid ObservationSource: %q", str)
25 | }
26 | *o = ObservationSource(v)
27 | return nil
28 | }
29 |
30 | var _ sql.Scanner = (*ObservationSource)(nil)
31 | var _ driver.Valuer = (*ObservationSource)(nil)
32 |
--------------------------------------------------------------------------------
/src/storage/gormstorage/operation.go:
--------------------------------------------------------------------------------
1 | package gormstorage
2 |
3 | import (
4 | "database/sql/driver"
5 | "fmt"
6 | "time"
7 |
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 | )
10 |
11 | var _ driver.Valuer = (*OperationStatus)(nil)
12 |
13 | type OperationStatus pb.Operation_Status
14 |
15 | func (o OperationStatus) Value() (driver.Value, error) {
16 | return pb.Operation_Status(o).String(), nil
17 | }
18 |
19 | func (o *OperationStatus) Scan(src interface{}) error {
20 | str, ok := src.(string)
21 | if !ok {
22 | return fmt.Errorf("expected string, got %T", src)
23 | }
24 | v, ok := pb.Operation_Status_value[str]
25 | if !ok {
26 | return fmt.Errorf("invalid OperationStatus: %q", str)
27 | }
28 | *o = OperationStatus(v)
29 | return nil
30 | }
31 |
32 | type Operation struct {
33 | ID string `gorm:"column:operationid;primaryKey;index:operations_idx"`
34 | ResourceGroup string `gorm:"column:resourcegroupname;index;primaryKey;index:operations_idx;index:operations_rgname_endtime"`
35 | OpsType string `gorm:"column:opstype;index;primaryKey;index:operations_idx"`
36 | StartTime time.Time `gorm:"column:starttime;index"`
37 | EndTime *time.Time `gorm:"column:endtime;index;index:operations_idx;index:operations_rgname_endtime"`
38 | Status OperationStatus `gorm:"index;index:operations_idx"`
39 | Reason string
40 | }
41 |
--------------------------------------------------------------------------------
/src/storage/gormstorage/severity.go:
--------------------------------------------------------------------------------
1 | package gormstorage
2 |
3 | import (
4 | pb "github.com/nianticlabs/modron/src/proto/generated"
5 | )
6 |
7 | // SeverityScore represents a score for the severity - this number is in the 0.0 - 10.0 range.
8 | type SeverityScore float32
9 |
10 | const (
11 | SeverityLowMax = 3.9
12 | SeverityMediumMax = 6.9
13 | SeverityHighMax = 8.9
14 |
15 | SeverityMin = 0.0
16 | SeverityMax = 10.0
17 | )
18 |
19 | func (s SeverityScore) ToSeverity() pb.Severity {
20 | if s < 0 {
21 | // This should never happen (SeverityScore should be nil if anything)
22 | return pb.Severity_SEVERITY_UNKNOWN
23 | }
24 | if s == SeverityMin {
25 | return pb.Severity_SEVERITY_INFO
26 | }
27 | if s <= SeverityLowMax {
28 | return pb.Severity_SEVERITY_LOW
29 | }
30 | if s <= SeverityMediumMax {
31 | return pb.Severity_SEVERITY_MEDIUM
32 | }
33 | if s <= SeverityHighMax {
34 | return pb.Severity_SEVERITY_HIGH
35 | }
36 | return pb.Severity_SEVERITY_CRITICAL
37 | }
38 |
39 | func FromSeverityPb(severity pb.Severity) *SeverityScore {
40 | switch severity {
41 | case pb.Severity_SEVERITY_INFO:
42 | info := SeverityScore(0.0)
43 | return &info
44 | case pb.Severity_SEVERITY_LOW:
45 | low := SeverityScore(SeverityLowMax)
46 | return &low
47 | case pb.Severity_SEVERITY_MEDIUM:
48 | medium := SeverityScore(SeverityMediumMax)
49 | return &medium
50 | case pb.Severity_SEVERITY_HIGH:
51 | high := SeverityScore(SeverityHighMax)
52 | return &high
53 | case pb.Severity_SEVERITY_CRITICAL:
54 | critical := SeverityScore(SeverityMax)
55 | return &critical
56 | }
57 | unknownScore := SeverityScore(-1)
58 | return &unknownScore
59 | }
60 |
--------------------------------------------------------------------------------
/src/storage/memstorage/memstorage.go:
--------------------------------------------------------------------------------
1 | package memstorage
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/sirupsen/logrus"
7 |
8 | "github.com/nianticlabs/modron/src/model"
9 | "github.com/nianticlabs/modron/src/storage/gormstorage"
10 | storageutils "github.com/nianticlabs/modron/src/storage/utils"
11 | )
12 |
13 | const DefaultBatchSize = 100
14 |
15 | var logger = logrus.StandardLogger()
16 |
17 | func New() model.Storage {
18 | dbPath := storageutils.GetSqliteMemoryDbPath()
19 | logger.Debugf("Using SQLite storage with path: %s", dbPath)
20 | st, err := gormstorage.NewSQLite(gormstorage.Config{
21 | BatchSize: DefaultBatchSize,
22 | LogAllQueries: os.Getenv("LOG_ALL_SQL_QUERIES") == "true",
23 | }, dbPath)
24 | if err != nil {
25 | // It's fine to panic here, memstorage should only be used in tests
26 | panic(err)
27 | }
28 | return st
29 | }
30 |
--------------------------------------------------------------------------------
/src/storage/storage.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | type Type string
4 |
5 | const (
6 | SQL Type = "sql"
7 | Memory Type = "memory"
8 | )
9 |
--------------------------------------------------------------------------------
/src/storage/utils/utils.go:
--------------------------------------------------------------------------------
1 | package storageutils
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/google/uuid"
8 | )
9 |
10 | func GetSqliteMemoryDbPath() string {
11 | debugDbPath := os.Getenv("DEBUG_DB_PATH")
12 | if debugDbPath != "" {
13 | return debugDbPath
14 | }
15 | // We use an uniqueID, so that two tests running in parallel do not conflict with each other
16 | uniqueID := uuid.NewString()
17 | // Do not use `:memory:` here! https://github.com/mattn/go-sqlite3/issues/204
18 | return fmt.Sprintf("file:%s?mode=memory&cache=shared", uniqueID)
19 | }
20 |
--------------------------------------------------------------------------------
/src/test/fake_notification_service.go:
--------------------------------------------------------------------------------
1 | package e2e
2 |
3 | import pb "github.com/nianticlabs/modron/src/proto/generated"
4 |
5 | func New() pb.NotificationServiceServer {
6 | return newFakeServer()
7 | }
8 |
9 | type FakeNotificationService struct {
10 | // Required
11 | pb.UnimplementedNotificationServiceServer
12 | }
13 |
14 | func newFakeServer() pb.NotificationServiceServer {
15 | return FakeNotificationService{}
16 | }
17 |
--------------------------------------------------------------------------------
/src/test/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/nianticlabs/modron/src/e2e_test
2 |
3 | go 1.23.2
4 |
5 | replace github.com/nianticlabs/modron/src/proto/generated => ../proto/generated
6 |
7 | require (
8 | github.com/google/go-cmp v0.6.0
9 | google.golang.org/grpc v1.67.1
10 | google.golang.org/protobuf v1.35.1
11 | github.com/nianticlabs/modron/src/proto/generated v0.0.0-00010101000000-000000000000
12 | )
13 |
14 | require (
15 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect
16 | github.com/go-logr/logr v1.4.2 // indirect
17 | github.com/gogo/protobuf v1.3.2 // indirect
18 | github.com/google/gofuzz v1.2.0 // indirect
19 | github.com/json-iterator/go v1.1.12 // indirect
20 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
21 | github.com/modern-go/reflect2 v1.0.2 // indirect
22 | github.com/x448/float16 v0.8.4 // indirect
23 | golang.org/x/net v0.30.0 // indirect
24 | golang.org/x/sys v0.26.0 // indirect
25 | golang.org/x/text v0.19.0 // indirect
26 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect
27 | gopkg.in/inf.v0 v0.9.1 // indirect
28 | gopkg.in/yaml.v2 v2.4.0 // indirect
29 | k8s.io/api v0.31.2 // indirect
30 | k8s.io/apimachinery v0.31.2 // indirect
31 | k8s.io/klog/v2 v2.130.1 // indirect
32 | k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 // indirect
33 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
34 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
35 | )
36 |
--------------------------------------------------------------------------------
/src/ui/.dockerignore:
--------------------------------------------------------------------------------
1 | **/node_modules
2 | **/.angular
--------------------------------------------------------------------------------
/src/ui/.gcloudignore:
--------------------------------------------------------------------------------
1 | *~
2 | **/node_modules
3 | **/.angular
--------------------------------------------------------------------------------
/src/ui/.gitignore:
--------------------------------------------------------------------------------
1 | .yarn
2 |
--------------------------------------------------------------------------------
/src/ui/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/src/ui/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG GOVERSION=1.23
2 | ARG NODE_VERSION=20
3 |
4 | FROM node:${NODE_VERSION}-alpine AS ui_builder
5 | WORKDIR /app
6 | COPY ./src/ui/client/ .
7 | RUN npm install
8 | RUN npm run build
9 |
10 | FROM golang:${GOVERSION} AS server_builder
11 | ENV GOPATH=/go
12 | WORKDIR /app
13 | COPY ./src/ui/go.* ./
14 | RUN go mod download
15 | COPY ./src/ui/ ./
16 | RUN CGO_ENABLED=0 go build -v -o modron-ui-server
17 |
18 | FROM alpine:latest AS ca-certificates_builder
19 | RUN apk add --no-cache ca-certificates
20 |
21 | # FROM scratch
22 | WORKDIR /app
23 | # COPY --from=ca-certificates_builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
24 | COPY --from=ui_builder /app/dist/ .
25 | COPY --from=server_builder /app/modron-ui-server .
26 | USER 101:101
27 | EXPOSE 8080
28 | ENTRYPOINT ["/app/modron-ui-server", "-logtostderr"]
29 |
--------------------------------------------------------------------------------
/src/ui/README.md:
--------------------------------------------------------------------------------
1 | # Modron UI
2 | User interface for the Modron service.
3 |
4 | ## Dependencies
5 | - Docker
6 | - Node.js LTS
7 | - Angular CLI
8 |
9 | ## How to run
10 |
11 | ```bash
12 | npm run dev # Run UI with mock gRPC server and envoy proxy
13 | ```
14 |
15 | Then navigate to `localhost:4200`.
16 |
17 |
18 | ## Developing on MacOS with [Lima](https://github.com/lima-vm/lima)
19 |
20 | ### Frontend with `mock-grcp-server`
21 |
22 | When you're developing against the `mock-grpc-server` (`npm run dev`), this will be the setup:
23 |
24 | ```mermaid
25 | flowchart LR
26 | subgraph Host
27 | frontend["frontend"]
28 | webpack_proxy["webpack-proxy"]
29 | end
30 | subgraph "Docker Host"
31 | subgraph docker
32 | envoy
33 | mgs["mock-grpc-server"]
34 | end
35 | end
36 |
37 | frontend -- 127.0.0.1:4000 --> webpack_proxy
38 | webpack_proxy -- 127.0.0.1:4201 --> envoy
39 | envoy --> mgs
40 | ```
41 |
42 | ### Frontend with Modron as a backend
43 |
44 | 1. Start the backend (either with `docker-compose.yml` or via `go run ./src`) and make sure it's listening on `:4201`
45 | 1. In this directory (`src/ui`), run `npm run dev:client`
46 |
--------------------------------------------------------------------------------
/src/ui/client/.browserslistrc:
--------------------------------------------------------------------------------
1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
2 | # For additional information regarding the format and rule options, please see:
3 | # https://github.com/browserslist/browserslist#queries
4 |
5 | # For the full list of supported browsers by the Angular framework, please see:
6 | # https://angular.io/guide/browser-support
7 |
8 | # You can see what browsers were selected by your queries by running:
9 | # npx browserslist
10 |
11 | last 1 Chrome version
12 | last 1 Firefox version
13 | last 2 Edge major versions
14 | last 2 Safari major versions
15 | last 2 iOS major versions
16 | Firefox ESR
17 |
--------------------------------------------------------------------------------
/src/ui/client/.dockerignore:
--------------------------------------------------------------------------------
1 | /cypress
2 | !/cypress/e2e
3 | !/cypress/components
4 | !/cypress/tsconfig.json
5 | /dist
6 | /results
7 |
--------------------------------------------------------------------------------
/src/ui/client/.editorconfig:
--------------------------------------------------------------------------------
1 | # Editor configuration, see https://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.ts]
12 | quote_type = double
13 |
14 | [*.md]
15 | max_line_length = off
16 | trim_trailing_whitespace = true
17 |
--------------------------------------------------------------------------------
/src/ui/client/.eslintignore:
--------------------------------------------------------------------------------
1 | /src/proto/**
2 |
--------------------------------------------------------------------------------
/src/ui/client/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "ignorePatterns": [
4 | "projects/**/*",
5 | "dist/**/*"
6 | ],
7 | "overrides": [
8 | {
9 | "files": [
10 | "*.ts"
11 | ],
12 | "extends": [
13 | "eslint:recommended",
14 | "plugin:@typescript-eslint/recommended",
15 | "plugin:@angular-eslint/recommended",
16 | "plugin:@angular-eslint/template/process-inline-templates"
17 | ],
18 | "rules": {
19 | "@angular-eslint/directive-selector": [
20 | "error",
21 | {
22 | "type": "attribute",
23 | "prefix": "app",
24 | "style": "camelCase"
25 | }
26 | ],
27 | "@angular-eslint/component-selector": [
28 | "error",
29 | {
30 | "type": "element",
31 | "prefix": "app",
32 | "style": "kebab-case"
33 | }
34 | ],
35 | "@typescript-eslint/quotes": [
36 | "error",
37 | "double"
38 | ],
39 | "@typescript-eslint/no-explicit-any": [
40 | "warn"
41 | ]
42 | }
43 | },
44 | {
45 | "files": [
46 | "*.html"
47 | ],
48 | "extends": [
49 | "plugin:@angular-eslint/template/recommended"
50 | ],
51 | "rules": {}
52 | }
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------
/src/ui/client/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-bookworm-slim
2 | RUN apt-get update && apt-get install -y gnupg wget
3 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
4 | RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
5 | RUN apt-get update && apt-get install -y google-chrome-stable
6 |
7 | WORKDIR /usr/src/app
8 | COPY . .
9 | RUN chown -R node:node /usr/src/app
10 | ENV CHROME_BIN=/usr/bin/google-chrome
11 | ENV USER=node
12 | ENV PATH="/home/node/.npm-global/bin:${PATH}"
13 | ENV NPM_CONFIG_PREFIX="/home/node/.npm-global"
14 | USER "${USER}"
15 |
16 | RUN mkdir -p "${NPM_CONFIG_PREFIX}/lib"
17 | COPY package.json package-lock.json ./
18 | RUN npm ci
19 |
20 | ENTRYPOINT npm run test:ci
21 |
--------------------------------------------------------------------------------
/src/ui/client/Dockerfile.e2e:
--------------------------------------------------------------------------------
1 | FROM cypress/base:20.9.0
2 | WORKDIR /app
3 | COPY ./src/ui/client/package.json .
4 | COPY ./src/ui/client/package-lock.json .
5 | ENV CI=1
6 | RUN npm ci
7 | RUN npx cypress verify
8 |
--------------------------------------------------------------------------------
/src/ui/client/README.md:
--------------------------------------------------------------------------------
1 | # Ui
2 |
3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.0.5.
4 |
5 | ## Development server
6 |
7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
8 |
9 | ## Code scaffolding
10 |
11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
12 |
13 | ## Build
14 |
15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
16 |
17 | ## Running unit tests
18 |
19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
20 |
21 | ## Running end-to-end tests
22 |
23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
24 |
25 | ## Further help
26 |
27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
28 |
--------------------------------------------------------------------------------
/src/ui/client/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "cypress"
2 |
3 | export default defineConfig({
4 |
5 | e2e: {
6 | baseUrl: "http://localhost:8080",
7 | supportFile: false
8 | },
9 | video: false,
10 | screenshotOnRunFailure: false,
11 | component: {
12 | devServer: {
13 | framework: "angular",
14 | bundler: "webpack",
15 | },
16 | specPattern: "**/*.cy.ts"
17 | },
18 |
19 | reporter: "junit",
20 | reporterOptions: {
21 | mochaFile: "/app/results/modron-e2e-ui-junit.xml",
22 | toConsole: false,
23 | },
24 |
25 | })
26 |
--------------------------------------------------------------------------------
/src/ui/client/cypress/.gitignore:
--------------------------------------------------------------------------------
1 | /screenshots
2 | /videos
3 |
--------------------------------------------------------------------------------
/src/ui/client/cypress/support/component-index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Components App
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/ui/client/cypress/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": ["**/*.ts"],
4 | "compilerOptions": {
5 | "sourceMap": false,
6 | "types": ["cypress"]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/ui/client/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration file, see link for more information
2 | // https://karma-runner.github.io/1.0/config/configuration-file.html
3 |
4 | module.exports = function (config) {
5 | config.set({
6 | basePath: '',
7 | frameworks: ['jasmine', '@angular-devkit/build-angular'],
8 | plugins: [
9 | require('karma-jasmine'),
10 | require('karma-chrome-launcher'),
11 | require('karma-jasmine-html-reporter'),
12 | require('karma-coverage'),
13 | require('karma-junit-reporter'),
14 | require('@angular-devkit/build-angular/plugins/karma')
15 | ],
16 | client: {
17 | jasmine: {
18 | // you can add configuration options for Jasmine here
19 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
20 | // for example, you can disable the random execution with `random: false`
21 | // or set a specific seed with `seed: 4321`
22 | },
23 | clearContext: false // leave Jasmine Spec Runner output visible in browser
24 | },
25 | jasmineHtmlReporter: {
26 | suppressAll: true // removes the duplicated traces
27 | },
28 | coverageReporter: {
29 | dir: require('path').join(__dirname, './coverage/ui'),
30 | subdir: '.',
31 | reporters: [
32 | { type: 'html' },
33 | { type: 'text-summary' },
34 | { type: 'cobertura', file: 'reports/modron.cobertura.xml' }
35 | ]
36 | },
37 | junitReporter: {
38 | outputDir: 'reports',
39 | outputFile: 'modron.junit.xml',
40 | useBrowserName: false,
41 | },
42 | reporters: ['progress', 'kjhtml', 'junit'],
43 | port: 9876,
44 | colors: true,
45 | logLevel: config.LOG_INFO,
46 | autoWatch: true,
47 | browsers: ['Chrome', 'ChromeHeadless', 'ChromeHeadlessCI'],
48 | customLaunchers: {
49 | ChromeHeadlessCI: {
50 | base: 'ChromeHeadless',
51 | flags: ['--no-sandbox']
52 | }
53 | },
54 | singleRun: false,
55 | restartOnFileChange: true
56 | });
57 | };
58 |
--------------------------------------------------------------------------------
/src/ui/client/src/.gitignore:
--------------------------------------------------------------------------------
1 | proto/
2 | !proto/.gitkeep
3 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/app-routing.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from "@angular/core"
2 | import { RouterModule, Routes } from "@angular/router"
3 | import { ModronAppComponent } from "./modron-app/modron-app.component"
4 | import { NotificationExceptionFormComponent } from "./notification-exception-form/notification-exception-form.component"
5 | import { NotificationExceptionsComponent } from "./notification-exceptions/notification-exceptions.component"
6 | import { ResourceGroupDetailsComponent } from "./resource-group-details/resource-group-details.component"
7 | import { ResourceGroupsComponent } from "./resource-groups/resource-groups.component"
8 | import { StatsComponent } from "./stats/stats.component"
9 | import {UIDemoComponent} from "./ui-demo/ui-demo.component";
10 |
11 | const routes: Routes = [
12 | {
13 | path: "modron",
14 | component: ModronAppComponent,
15 | children: [
16 | { path: "resourcegroups", component: ResourceGroupsComponent },
17 | { path: "resourcegroup/:id", component: ResourceGroupDetailsComponent },
18 | { path: "stats", component: StatsComponent },
19 | { path: "exceptions", component: NotificationExceptionsComponent },
20 | {
21 | path: "exceptions/:notificationName",
22 | component: NotificationExceptionsComponent,
23 | },
24 | {
25 | path: "exceptions/new/:notificationName",
26 | component: NotificationExceptionFormComponent,
27 | },
28 | {
29 | path: "ui-demo",
30 | component: UIDemoComponent,
31 | }
32 | ],
33 | },
34 |
35 | // otherwise redirect to home
36 | { path: "**", redirectTo: "modron/resourcegroups" },
37 | ]
38 |
39 | @NgModule({
40 | imports: [RouterModule.forRoot(routes, {
41 | anchorScrolling: "enabled"
42 | })],
43 | exports: [RouterModule],
44 | })
45 | export class AppRoutingModule { }
46 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/app.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | An unexpected error has occurred while authenticating. Please try to refresh
6 | the page in a couple of seconds.
7 |
8 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/app.component.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/app/app.component.scss
--------------------------------------------------------------------------------
/src/ui/client/src/app/app.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestBed } from "@angular/core/testing";
2 | import { RouterTestingModule } from "@angular/router/testing";
3 | import { AppComponent } from "./app.component";
4 | import { AuthenticationStore } from "./state/authentication.store";
5 | import { ModronStore } from "./state/modron.store";
6 | import { NotificationStore } from "./state/notification.store";
7 |
8 | describe("AppComponent", () => {
9 | beforeEach(async () => {
10 | await TestBed.configureTestingModule({
11 | imports: [RouterTestingModule],
12 | declarations: [AppComponent],
13 | providers: [AuthenticationStore, NotificationStore, ModronStore],
14 | }).compileComponents();
15 | });
16 |
17 | it("should create the app", () => {
18 | const fixture = TestBed.createComponent(AppComponent);
19 | const app = fixture.componentInstance;
20 | expect(app).toBeTruthy();
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from "@angular/core"
2 | import { environment } from "src/environments/environment"
3 | import { AuthenticationStore } from "./state/authentication.store"
4 | import { ModronStore } from "./state/modron.store"
5 | import { NotificationStore } from "./state/notification.store"
6 |
7 | @Component({
8 | selector: "app-root",
9 | templateUrl: "./app.component.html",
10 | styleUrls: ["./app.component.scss"],
11 | })
12 | export class AppComponent {
13 | constructor(
14 | public auth: AuthenticationStore,
15 | public modron: ModronStore,
16 | public notification: NotificationStore
17 | ) { }
18 |
19 | get local(): boolean {
20 | return environment.local
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/authentication.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestBed } from "@angular/core/testing";
2 |
3 | import { AuthenticationService } from "./authentication.service";
4 |
5 | describe("AuthenticationServiceService", () => {
6 | let service: AuthenticationService;
7 |
8 | beforeEach(() => {
9 | TestBed.configureTestingModule({});
10 | service = TestBed.inject(AuthenticationService);
11 | });
12 |
13 | it("should be created", () => {
14 | expect(service).toBeTruthy();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/authentication.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core";
2 | import { CookieService } from "ngx-cookie-service";
3 |
4 | export class User {
5 | constructor(private _signedIn: boolean, private _email = "") {}
6 |
7 | get email(): string {
8 | return this._email;
9 | }
10 |
11 | get signedIn(): boolean {
12 | return this._signedIn;
13 | }
14 | }
15 |
16 | @Injectable({
17 | providedIn: "root",
18 | })
19 | export class AuthenticationService {
20 | public static readonly USER_EMAIL_COOKIE_NAME = "modron-user-email";
21 |
22 | constructor(private _service: CookieService) {}
23 |
24 | authenticate(): User {
25 | const email = this._service.get(
26 | AuthenticationService.USER_EMAIL_COOKIE_NAME
27 | );
28 | if (email !== "") {
29 | return new User(true, email);
30 | }
31 | return new User(false);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/filter.pipe.spec.ts:
--------------------------------------------------------------------------------
1 | import { FilterKeyValuePipe, FilterNoObservationsPipe } from "./filter.pipe";
2 |
3 | describe("FilterKeyValuePipe", () => {
4 | it("create an instance", () => {
5 | const pipe = new FilterKeyValuePipe();
6 | expect(pipe).toBeTruthy();
7 | });
8 | });
9 |
10 | describe("filterNoObservations", () => {
11 | it("create an instance", () => {
12 | const pipe = new FilterNoObservationsPipe();
13 | expect(pipe).toBeTruthy();
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/grpc-token.interceptor.ts:
--------------------------------------------------------------------------------
1 | export class GrpcTokenInterceptor {
2 | constructor(private _token: string) {}
3 |
4 | // TODO: The @improbable-en/grpc-web impl does not support
5 | // client interceptors. So we mimick an interceptor by returning metadata
6 | // that contains the grpc auth token.
7 | get intercept() {
8 | return { Authorization: `Bearer ${this._token}` };
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/impact-indicator/impact-indicator.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/impact-indicator/impact-indicator.component.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/app/impact-indicator/impact-indicator.component.scss
--------------------------------------------------------------------------------
/src/ui/client/src/app/impact-indicator/impact-indicator.component.ts:
--------------------------------------------------------------------------------
1 | import {Component, Input} from "@angular/core";
2 | import {Impact, Severity} from "../../proto/modron_pb";
3 |
4 | @Component(
5 | {
6 | selector: "app-impact-indicator",
7 | templateUrl: "./impact-indicator.component.html",
8 | styleUrls: ["./impact-indicator.component.scss"],
9 | }
10 | )
11 | export class ImpactIndicatorComponent {
12 | @Input()
13 | public impact: Impact = Impact.IMPACT_UNKNOWN;
14 | constructor() {
15 | }
16 |
17 | // We reuse the severity indicator component to display the impact
18 | public severity(): Severity {
19 | switch (this.impact) {
20 | case Impact.IMPACT_HIGH:
21 | return Severity.SEVERITY_HIGH;
22 | case Impact.IMPACT_MEDIUM:
23 | return Severity.SEVERITY_MEDIUM;
24 | case Impact.IMPACT_LOW:
25 | return Severity.SEVERITY_LOW;
26 | default:
27 | return Severity.SEVERITY_UNKNOWN;
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/model/modron.model.ts:
--------------------------------------------------------------------------------
1 | import {RequestStatus, ScanType} from "../../proto/modron_pb";
2 |
3 | export type StatusInfo = {
4 | state: RequestStatus
5 | resourceGroups: string[]
6 | scanType: ScanType
7 | }
8 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/model/notification.model.ts:
--------------------------------------------------------------------------------
1 | import * as pb from "src/proto/notification_pb";
2 | import * as timestampPb from "google-protobuf/google/protobuf/timestamp_pb"
3 |
4 | export class NotificationException {
5 | private _uuid?: string
6 | private _createdOnTime?: Date
7 |
8 | sourceSystem = ""
9 | userEmail = ""
10 | notificationName = ""
11 | justification = ""
12 | validUntilTime?: Date
13 |
14 | get uuid(): string {
15 | if (this._uuid === undefined) {
16 | throw new Error("exception has not been created")
17 | }
18 | return this._uuid
19 | }
20 |
21 | get createdOnTime(): Date {
22 | if (this._createdOnTime === undefined) {
23 | throw new Error("exception has not been created")
24 | }
25 | return this._createdOnTime
26 | }
27 |
28 | toProto(): pb.NotificationException {
29 | const proto = new pb.NotificationException()
30 | proto.setSourceSystem(this.sourceSystem)
31 | proto.setUserEmail(this.userEmail)
32 | proto.setNotificationName(this.notificationName)
33 | proto.setJustification(this.justification)
34 | if (this.validUntilTime !== undefined) {
35 | proto.setValidUntilTime(timestampPb.Timestamp.fromDate(this.validUntilTime))
36 | }
37 | return proto
38 | }
39 |
40 | static fromProto(proto: pb.NotificationException): NotificationException {
41 | const model = new NotificationException()
42 | model._uuid = proto.getUuid()
43 | model.sourceSystem = proto.getSourceSystem()
44 | model.userEmail = proto.getUserEmail()
45 | model.notificationName = proto.getNotificationName()
46 | model.justification = proto.getJustification()
47 | model.validUntilTime = proto.getValidUntilTime()?.toDate()
48 | model._createdOnTime = proto.getCreatedOnTime()?.toDate()
49 | return model
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/modron-app/modron-app.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 | Modron
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/modron-app/modron-app.component.scss:
--------------------------------------------------------------------------------
1 | @use '../../colors.scss' as colors;
2 |
3 | .modron-app {
4 | position: absolute;
5 | display: flex;
6 | top: 0;
7 | left: 0;
8 | right: 0;
9 | bottom: 0;
10 |
11 | .sidenav {
12 | top: 0;
13 | bottom: 0;
14 | position: fixed;
15 | display: flex;
16 | }
17 |
18 | .app-content {
19 | position: relative;
20 | margin-left: 68px;
21 | width: calc(100% - 68px);
22 |
23 | .toolbar {
24 | position: fixed;
25 | display: flex;
26 | height: 60px;
27 | z-index: 300;
28 |
29 | img.logo {
30 | height: 60%;
31 | margin-right: 4px;
32 | }
33 |
34 | .app-title {
35 | font-weight: 400;
36 | color: colors.$title;
37 | text-transform: uppercase;
38 | }
39 | }
40 |
41 | .router-container {
42 | margin: 16px;
43 | margin-top: 60px;
44 | overflow: hidden;
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/modron-app/modron-app.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing"
2 |
3 | import { ModronAppComponent } from "./modron-app.component"
4 |
5 | describe("ModronAppComponent", () => {
6 | let component: ModronAppComponent
7 | let fixture: ComponentFixture
8 |
9 | beforeEach(async () => {
10 | await TestBed.configureTestingModule({
11 | declarations: [ModronAppComponent],
12 | }).compileComponents()
13 |
14 | fixture = TestBed.createComponent(ModronAppComponent)
15 | component = fixture.componentInstance
16 | fixture.detectChanges()
17 | })
18 |
19 | it("should create", () => {
20 | expect(component).toBeTruthy()
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/modron-app/modron-app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, EventEmitter, Input, Output } from "@angular/core"
2 | import { environment } from "../../environments/environment"
3 | import { Router } from "@angular/router";
4 |
5 | @Component({
6 | selector: "app-modron-app",
7 | templateUrl: "./modron-app.component.html",
8 | styleUrls: ["./modron-app.component.scss"],
9 | })
10 | export class ModronAppComponent {
11 | public organization: string
12 | public href= "";
13 |
14 | @Input() isExpanded: boolean = false;
15 | @Output() toggleMenu = new EventEmitter();
16 |
17 | constructor(private router: Router) {
18 | this.organization = environment.organization
19 | }
20 |
21 | get production(): boolean {
22 | return environment.production
23 | }
24 |
25 | get currentUrl(): string {
26 | return this.router.url;
27 | }
28 |
29 | public navItems = [
30 | { link: "/modron/resourcegroups", name: "Resource Groups", icon: "folder" },
31 | { link: "/modron/stats", name: "Stats", icon: "bar_chart" },
32 | { link: "/modron/exceptions", name: "Exceptions", icon: "notifications_paused" },
33 | ];
34 | }
35 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/modron.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestBed } from "@angular/core/testing";
2 | import { ModronService } from "./modron.service";
3 |
4 | describe("ModronService", () => {
5 | let service: ModronService;
6 |
7 | beforeEach(() => {
8 | TestBed.configureTestingModule({});
9 | service = TestBed.inject(ModronService);
10 | });
11 |
12 | it("should be created", () => {
13 | expect(service).toBeTruthy();
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/notif-bell-button/notif-bell-button.component.html:
--------------------------------------------------------------------------------
1 |
37 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/notif-bell-button/notif-bell-button.component.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/app/notif-bell-button/notif-bell-button.component.scss
--------------------------------------------------------------------------------
/src/ui/client/src/app/notification-exception-form/notification-exception-form.component.scss:
--------------------------------------------------------------------------------
1 | .form {
2 | min-width: 150px;
3 | max-width: 500px;
4 | width: 100%;
5 | }
6 |
7 | .form-field-fill {
8 | width: 100%;
9 | }
10 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/notification-exceptions/notification-exceptions.component.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | display: flex;
3 | flex-direction: row;
4 | align-items: center;
5 | gap: 10px;
6 | flex: 1;
7 | background-color: rgb(239, 239, 239);
8 | margin-bottom: 10px;
9 |
10 | .exceptions-filter {
11 | display: flex;
12 | flex-direction: row;
13 | gap: 10px;
14 |
15 | input {
16 | padding: 5px;
17 | margin: 5px;
18 | font-size: 20px;
19 | }
20 |
21 | input:focus {
22 | outline: none;
23 | }
24 | }
25 |
26 | h1 {
27 | margin: 5px 40px;
28 | }
29 | }
30 |
31 | table {
32 | width: 100%;
33 | font-family: "IBM Plex Sans", sans-serif;
34 |
35 | th,
36 | td {
37 | font-size: 18px;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/notification-exceptions/notification-exceptions.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing";
2 | import { ActivatedRoute } from "@angular/router";
3 | import { AuthenticationStore } from "../state/authentication.store";
4 | import { NotificationStore } from "../state/notification.store";
5 |
6 | import { NotificationExceptionsComponent } from "./notification-exceptions.component";
7 | import { NotificationExceptionsFilterPipe } from "./notification-exceptions.pipe";
8 |
9 | describe("NotificationExceptionsComponent", () => {
10 | let component: NotificationExceptionsComponent;
11 | let fixture: ComponentFixture;
12 |
13 | beforeEach(async () => {
14 | await TestBed.configureTestingModule({
15 | declarations: [
16 | NotificationExceptionsComponent,
17 | NotificationExceptionsFilterPipe,
18 | ],
19 | providers: [
20 | NotificationStore,
21 | AuthenticationStore,
22 | {
23 | provide: ActivatedRoute,
24 | useValue: {
25 | snapshot: {
26 | paramMap: {
27 | get(): string {
28 | return "mock-notification-name";
29 | },
30 | },
31 | },
32 | },
33 | },
34 | ],
35 | }).compileComponents();
36 |
37 | fixture = TestBed.createComponent(NotificationExceptionsComponent);
38 | component = fixture.componentInstance;
39 | fixture.detectChanges();
40 | });
41 |
42 | it("should create", () => {
43 | expect(component).toBeTruthy();
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/notification-exceptions/notification-exceptions.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from "@angular/core"
2 | import { ActivatedRoute } from "@angular/router"
3 | import { NotificationStore } from "../state/notification.store"
4 |
5 | @Component({
6 | selector: "app-notification-exceptions",
7 | templateUrl: "./notification-exceptions.component.html",
8 | styleUrls: ["./notification-exceptions.component.scss"],
9 | })
10 | export class NotificationExceptionsComponent {
11 | displayedColumns = [
12 | "userEmail",
13 | "notificationName",
14 | "justification",
15 | "sourceSystem",
16 | "validUntilTime",
17 | "$actions",
18 | ];
19 | searchText: string
20 |
21 | constructor(route: ActivatedRoute, public store: NotificationStore) {
22 | this.searchText = route.snapshot.paramMap.get("notificationName") ?? ""
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/notification-exceptions/notification-exceptions.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Pipe, PipeTransform } from "@angular/core"
2 | import { NotificationException } from "../model/notification.model"
3 |
4 | @Pipe({
5 | name: "filterExceptions",
6 | })
7 | export class NotificationExceptionsFilterPipe implements PipeTransform {
8 | transform(exps: NotificationException[], searchText: string): NotificationException[] {
9 | searchText = searchText.toLocaleLowerCase()
10 |
11 | return exps.filter((exp) => {
12 | return exp.notificationName.toLocaleLowerCase().includes(searchText)
13 | })
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/notification.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestBed } from "@angular/core/testing";
2 | import { NotificationService } from "./notification.service";
3 |
4 | describe("ModronService", () => {
5 | let service: NotificationService;
6 |
7 | beforeEach(() => {
8 | TestBed.configureTestingModule({});
9 | service = TestBed.inject(NotificationService);
10 | });
11 |
12 | it("should be created", () => {
13 | expect(service).toBeTruthy();
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observation-details-dialog-content/observation-details-dialog-content.component.scss:
--------------------------------------------------------------------------------
1 | .dialog-content {
2 | display: flex;
3 | flex-direction: column;
4 | width: 100%;
5 |
6 | h1, h2, h3, h4, h5, h6 {
7 | margin-bottom: 0.5em;
8 | }
9 |
10 | p {
11 | margin-top: 0;
12 | }
13 | }
14 |
15 | .observation-details {
16 | display: grid;
17 | grid-template-columns: 1fr 2fr;
18 | margin: 16px;
19 | row-gap: 4px;
20 |
21 | p {
22 | margin: 0;
23 | }
24 |
25 | .hdr {
26 | font-weight: bold;
27 | }
28 | }
29 |
30 | .risk-score {
31 | display: grid;
32 | grid-template-columns: 80px 1fr;
33 | column-gap: 8px;
34 | align-items: center;
35 | width: 100%;
36 |
37 | .main-indicator {
38 | justify-self: center;
39 | }
40 |
41 | .severity-impact-container {
42 | display: grid;
43 | align-items: start;
44 | row-gap: 18px;
45 | grid-template-columns: 80px 1fr;
46 | margin-top: 1em;
47 |
48 | .indicator {
49 | justify-self: center;
50 | }
51 |
52 | div.expl {
53 | h1,h2,h3,h4,h5,h6 {
54 | margin: 0 0 0.5em;
55 | }
56 |
57 | code {
58 | background-color: #eeeeee;
59 | padding: 3px;
60 | }
61 |
62 | }
63 | }
64 | }
65 |
66 | .short-risk-score-description {
67 | margin-top: 1em;
68 | margin-bottom: 1em;
69 | }
70 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observation-details-dialog-content/observation-details-dialog-content.component.ts:
--------------------------------------------------------------------------------
1 | import {Component, Input} from "@angular/core";
2 | import {Impact, Observation} from "../../proto/modron_pb";
3 |
4 | @Component({
5 | selector: "app-observation-details-dialog-content",
6 | templateUrl: "./observation-details-dialog-content.component.html",
7 | styleUrls: ["./observation-details-dialog-content.component.scss"],
8 | })
9 | export class ObservationDetailsDialogContentComponent {
10 | @Input()
11 | observation!: Observation;
12 | constructor() {}
13 |
14 | protected readonly Impact = Impact;
15 | }
16 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observation-details-dialog-content/observation-details-dialog-content.filter.ts:
--------------------------------------------------------------------------------
1 | import {Pipe, PipeTransform} from "@angular/core";
2 | import {Observation} from "../../proto/modron_pb";
3 | import Category = Observation.Category;
4 |
5 | @Pipe({
6 | name: "categoryName"
7 | })
8 | export class CategoryNamePipe implements PipeTransform {
9 | transform(cat: Category): string {
10 | switch(cat) {
11 | case Category.CATEGORY_VULNERABILITY:
12 | return "Vulnerability";
13 | case Category.CATEGORY_MISCONFIGURATION:
14 | return "Misconfiguration";
15 | case Category.CATEGORY_TOXIC_COMBINATION:
16 | return "Toxic Combination";
17 | default:
18 | return "Unknown";
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observation-details-dialog/observation-details-dialog.component.html:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observation-details-dialog/observation-details-dialog.component.scss:
--------------------------------------------------------------------------------
1 | .observation-details-dialog {
2 | padding: 20px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observation-details-dialog/observation-details-dialog.component.ts:
--------------------------------------------------------------------------------
1 | import {Component, Inject} from "@angular/core";
2 | import {MAT_DIALOG_DATA} from "@angular/material/dialog";
3 | import {Observation} from "../../proto/modron_pb";
4 |
5 | @Component(
6 | {
7 | selector: "app-observation-details-dialog",
8 | templateUrl: "./observation-details-dialog.component.html",
9 | styleUrls: ["./observation-details-dialog.component.scss"],
10 | }
11 | )
12 | export class ObservationDetailsDialogComponent {
13 | constructor(
14 | @Inject(MAT_DIALOG_DATA) public observation: Observation
15 | ) {
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observation-details/observation-details.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing";
2 | import { MatDialogModule } from "@angular/material/dialog";
3 | import { MatSnackBarModule } from "@angular/material/snack-bar";
4 | import { NotificationExceptionsFilterPipe } from "../notification-exceptions/notification-exceptions.pipe";
5 | import { AuthenticationStore } from "../state/authentication.store";
6 | import { NotificationStore } from "../state/notification.store";
7 |
8 | import { ObservationDetailsComponent } from "./observation-details.component";
9 | import { ParseExternalIdPipe, ShortenDescriptionPipe } from "../filter.pipe";
10 | import {ImpactNamePipe, SeverityNamePipe} from "../severity-indicator/severity-indicator.pipe";
11 |
12 | describe("ObservationDetailsComponent", () => {
13 | let component: ObservationDetailsComponent;
14 | let fixture: ComponentFixture;
15 |
16 | beforeEach(async () => {
17 | await TestBed.configureTestingModule({
18 | imports: [MatDialogModule, MatSnackBarModule],
19 | declarations: [
20 | ObservationDetailsComponent,
21 | NotificationExceptionsFilterPipe,
22 | ImpactNamePipe,
23 | ParseExternalIdPipe,
24 | SeverityNamePipe,
25 | ShortenDescriptionPipe,
26 | ],
27 | providers: [AuthenticationStore, NotificationStore],
28 | }).compileComponents();
29 |
30 | fixture = TestBed.createComponent(ObservationDetailsComponent);
31 | component = fixture.componentInstance;
32 | fixture.detectChanges();
33 | });
34 |
35 | it("should create", () => {
36 | expect(component).toBeTruthy();
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observations-stats/observations-stats.component.html:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observations-stats/observations-stats.component.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/app/observations-stats/observations-stats.component.scss
--------------------------------------------------------------------------------
/src/ui/client/src/app/observations-stats/observations-stats.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing";
2 |
3 | import { ObservationsStatsComponent } from "./observations-stats.component";
4 |
5 | describe("HistogramHorizontalComponent", () => {
6 | let component: ObservationsStatsComponent;
7 | let fixture: ComponentFixture;
8 |
9 | beforeEach(async () => {
10 | await TestBed.configureTestingModule({
11 | declarations: [ObservationsStatsComponent],
12 | }).compileComponents();
13 |
14 | fixture = TestBed.createComponent(ObservationsStatsComponent);
15 | component = fixture.componentInstance;
16 | fixture.detectChanges();
17 | });
18 |
19 | it("should create", () => {
20 | expect(component).toBeTruthy();
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observations-stats/observations-stats.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | OnInit,
4 | Input,
5 | ChangeDetectionStrategy,
6 | } from "@angular/core"
7 | import {ChartData, ChartOptions} from "chart.js";
8 |
9 | @Component({
10 | changeDetection: ChangeDetectionStrategy.OnPush,
11 | selector: "app-observations-stats",
12 | templateUrl: "./observations-stats.component.html",
13 | styleUrls: ["./observations-stats.component.scss"],
14 | })
15 | export class ObservationsStatsComponent implements OnInit {
16 | @Input() data: Map = new Map();
17 | public options: ChartOptions = {
18 | scales: {
19 |
20 | },
21 | indexAxis: "y",
22 | plugins: {
23 | legend: {
24 | display: false,
25 | },
26 | }
27 | }
28 | public chartData: ChartData = {
29 | labels: [] as string[],
30 | datasets: [
31 | {
32 | label: "Observations",
33 | data: [] as number[],
34 | }
35 | ]
36 | };
37 | max = 1;
38 |
39 | ngOnInit(): void {
40 | this.max = Math.max(...this.data.values())
41 | this.chartData.labels = Array.from(this.data.keys());
42 | this.chartData.datasets[0].data = Array.from(this.data.values());
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/observations-table/observations-table.component.scss:
--------------------------------------------------------------------------------
1 | .mat-column-riskScore {
2 | max-width: 80px;
3 | }
4 |
5 | .mat-column-category {
6 | max-width: 350px;
7 | }
8 |
9 | .mat-column-shortDesc {
10 | min-width: 30%;
11 | }
12 |
13 | .mat-column-shortDesc, .mat-column-category {
14 | max-height: 1em;
15 | overflow: hidden;
16 | text-overflow: ellipsis;
17 | white-space: nowrap;
18 | min-width: 0;
19 |
20 | div {
21 | width: 100%;
22 | overflow: hidden;
23 | text-overflow: ellipsis;
24 | white-space: nowrap;
25 | }
26 | }
27 |
28 | .mat-cell {
29 | white-space: nowrap;
30 | overflow: hidden;
31 | text-overflow: ellipsis;
32 |
33 | > p {
34 | width: 100%;
35 | overflow: hidden;
36 | text-overflow: ellipsis;
37 | }
38 | }
39 |
40 | .mat-column-actions {
41 | display: grid;
42 | grid-template-columns: repeat(2, 1fr);
43 | justify-items: center;
44 | max-width: 100px;
45 |
46 | > * {
47 | cursor: pointer;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/resource-group-details/resource-group-details.component.html:
--------------------------------------------------------------------------------
1 |
2 |
19 |
20 |
21 |
22 |
23 |
26 |
27 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/resource-group-details/resource-group-details.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing"
2 | import { ViewportScroller } from "@angular/common"
3 | import { ActivatedRoute } from "@angular/router"
4 | import { reverseSortPipe } from "../filter.pipe"
5 | import { ObservationsPipe } from "../resource-groups/resource-groups.pipe"
6 | import { AuthenticationStore } from "../state/authentication.store"
7 | import { ModronStore } from "../state/modron.store"
8 | import { NotificationStore } from "../state/notification.store"
9 |
10 | import { ResourceGroupDetailsComponent } from "./resource-group-details.component"
11 |
12 | describe("ResourceGroupDetailsComponent", () => {
13 | let component: ResourceGroupDetailsComponent
14 | let fixture: ComponentFixture
15 |
16 | beforeEach(async () => {
17 | await TestBed.configureTestingModule({
18 | declarations: [
19 | ResourceGroupDetailsComponent,
20 | ObservationsPipe,
21 | reverseSortPipe,
22 | ],
23 | providers: [
24 | ModronStore,
25 | NotificationStore,
26 | AuthenticationStore,
27 | ViewportScroller,
28 | {
29 | provide: ActivatedRoute,
30 | useValue: {
31 | snapshot: {
32 | paramMap: {
33 | get(): string {
34 | return "mock-observation-id"
35 | },
36 | },
37 | },
38 | },
39 | },
40 | ],
41 | }).compileComponents()
42 |
43 | fixture = TestBed.createComponent(ResourceGroupDetailsComponent)
44 | component = fixture.componentInstance
45 | fixture.detectChanges()
46 | })
47 |
48 | it("should create", () => {
49 | expect(component).toBeTruthy()
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/resource-group-details/resource-group-details.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Pipe, PipeTransform } from "@angular/core"
2 | import * as pb from "src/proto/modron_pb"
3 | import {StructValueToStringPipe} from "../filter.pipe";
4 |
5 | @Pipe({ name: "mapByType" })
6 | export class MapByTypePipe implements PipeTransform {
7 | transform(obs: Map): Map {
8 | const obsByType = new Map()
9 | for (const ob of [...obs.values()].flat()) {
10 | const type = ob.getName()
11 | if (!obsByType.has(type)) {
12 | obsByType.set(type, [])
13 | }
14 | obsByType.get(type)?.push(ob)
15 | }
16 | return obsByType
17 | }
18 | }
19 |
20 | @Pipe({ name: "mapFlatRules" })
21 | export class mapFlatRulesPipe implements PipeTransform {
22 | transform(
23 | map: Map>
24 | ): Map {
25 | const res = new Map()
26 | map.forEach((v, k) => {
27 | res.set(k, Array.from(v.values()).flat())
28 | })
29 | return res
30 | }
31 | }
32 |
33 | @Pipe({ name: "mapByObservedValues" })
34 | export class MapByObservedValuesPipe implements PipeTransform {
35 | transform(obs: pb.Observation[]): Map {
36 | const obsByType = new Map()
37 | obs.forEach((o) => {
38 | const obsValue = o.getObservedValue()
39 | ? StructValueToStringPipe.prototype.transform(o.getObservedValue())
40 | : "Observation count"
41 | if (!obsByType.has(obsValue)) {
42 | obsByType.set(obsValue, 0)
43 | }
44 | obsByType.set(obsValue, (obsByType.get(obsValue) as number) + 1)
45 | })
46 | return obsByType
47 | }
48 | }
49 |
50 | @Pipe({ name: "filterName" })
51 | export class FilterNamePipe implements PipeTransform {
52 | transform(
53 | obs: Map | null,
54 | name: string | null
55 | ): Map {
56 | const obsByType = new Map()
57 | obsByType.set(
58 | name ? name : "",
59 | obs?.get(name ? name : "")
60 | ? (obs.get(name ? name : "") as pb.Observation[])
61 | : []
62 | )
63 | return obsByType
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/resource-group/resource-group.component.html:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
0; else noFindings">
12 |
20 |
21 |
22 |
23 |
26 |
27 |
28 |
32 | Last scanned: {{ this.lastScanDate! | fromNow }}
33 |
34 |
35 |
36 |
37 | Never scanned
38 |
39 |
40 |
41 |
42 |
49 |
50 |
51 |
58 |
59 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/resource-group/resource-group.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing"
2 | import { ModronStore } from "../state/modron.store"
3 | import { ResourceGroupComponent } from "./resource-group.component"
4 | import { MatSnackBarModule } from "@angular/material/snack-bar"
5 |
6 | describe("ResourceGroupComponent", () => {
7 | let component: ResourceGroupComponent
8 | let fixture: ComponentFixture
9 |
10 | beforeEach(async () => {
11 | await TestBed.configureTestingModule({
12 | declarations: [ResourceGroupComponent],
13 | imports: [MatSnackBarModule],
14 | providers: [ModronStore],
15 | }).compileComponents()
16 |
17 | fixture = TestBed.createComponent(ResourceGroupComponent)
18 | component = fixture.componentInstance
19 | fixture.detectChanges()
20 | })
21 |
22 | it("should create", () => {
23 | expect(component).toBeTruthy()
24 | })
25 | })
26 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/resource-group/resource-group.pipe.ts:
--------------------------------------------------------------------------------
1 | import {Pipe, PipeTransform} from "@angular/core";
2 | import * as moment from "moment";
3 |
4 | @Pipe({name: "fromNow"})
5 | export class FromNowPipe implements PipeTransform {
6 | transform(value: Date): string {
7 | return moment(value).fromNow()
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/resource-groups/resource-groups.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing";
2 | import { MatSnackBarModule } from "@angular/material/snack-bar";
3 | import { FilterKeyValuePipe, FilterNoObservationsPipe } from "../filter.pipe";
4 | import { mapFlatRulesPipe } from "../resource-group-details/resource-group-details.pipe";
5 | import { ModronStore } from "../state/modron.store";
6 | import { RouterTestingModule } from "@angular/router/testing";
7 | import { provideHttpClientTesting } from "@angular/common/http/testing";
8 |
9 | import { ResourceGroupsComponent } from "./resource-groups.component";
10 | import {
11 | InvalidProjectNb,
12 | ObsNbPipe,
13 | ResourceGroupsPipe,
14 | } from "./resource-groups.pipe";
15 | import { provideHttpClient, withInterceptorsFromDi } from "@angular/common/http";
16 |
17 | describe("ResourceGroupsComponent", () => {
18 | let component: ResourceGroupsComponent;
19 | let fixture: ComponentFixture;
20 |
21 | beforeEach(async () => {
22 | await TestBed.configureTestingModule({
23 | declarations: [
24 | ResourceGroupsComponent,
25 | ResourceGroupsPipe,
26 | FilterKeyValuePipe,
27 | mapFlatRulesPipe,
28 | InvalidProjectNb,
29 | ObsNbPipe,
30 | FilterNoObservationsPipe,
31 | ],
32 | imports: [MatSnackBarModule,
33 | RouterTestingModule],
34 | providers: [ModronStore, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]
35 | }).compileComponents();
36 |
37 | fixture = TestBed.createComponent(ResourceGroupsComponent);
38 | component = fixture.componentInstance;
39 | fixture.detectChanges();
40 | });
41 |
42 | it("should create", () => {
43 | expect(component).toBeTruthy();
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/resource-groups/resource-groups.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Pipe, PipeTransform } from "@angular/core"
2 | import * as pb from "src/proto/modron_pb"
3 |
4 | @Pipe({ name: "resourceGroups" })
5 | export class ResourceGroupsPipe implements PipeTransform {
6 | transform(obs: Map): string[] {
7 | return [...obs.keys()]
8 | }
9 | }
10 |
11 | @Pipe({ name: "mapPerTypeName" })
12 | export class MapPerTypeName implements PipeTransform {
13 | transform(obs: Map): Map {
14 | const obsn: Map = new Map<
15 | string,
16 | pb.Observation[]
17 | >()
18 |
19 | obsn.set("START", [])
20 | obsn.set(JSON.stringify(obs.size), [])
21 | obsn.set("END", [])
22 |
23 | return obsn
24 | }
25 | }
26 |
27 |
28 | @Pipe({ name: "mapByRiskScore" })
29 | export class MapByRiskScorePipe implements PipeTransform {
30 | transform(obs: pb.Observation[]): [number, number][] {
31 | const sevMap = new Map();
32 | for(const o of obs) {
33 | const amountSeverities = sevMap.get(o.getRiskScore());
34 | if(amountSeverities === undefined){
35 | sevMap.set(o.getRiskScore(), 1);
36 | continue;
37 | }
38 | sevMap.set(o.getRiskScore(), amountSeverities+1);
39 | }
40 | return Array.from(sevMap.entries()).sort((a, b) => b[0] - a[0])
41 | }
42 | }
43 |
44 | @Pipe({ name: "observations" })
45 | export class ObservationsPipe implements PipeTransform {
46 | transform(obs: Map): pb.Observation[] {
47 | return [...obs.values()].flat()
48 | }
49 | }
50 |
51 | @Pipe({ name: "invalidProjectNb" })
52 | export class InvalidProjectNb implements PipeTransform {
53 | transform(obs: any[]): number {
54 | let res = 0
55 | obs.forEach((e) => {
56 | if (e.value.length > 0) {
57 | res += 1
58 | }
59 | })
60 | return res
61 | }
62 | }
63 |
64 | @Pipe({ name: "obsNb" })
65 | export class ObsNbPipe implements PipeTransform {
66 | transform(obs: any[]): number {
67 | let res = 0
68 | obs.forEach((e) => (res += e.value.length))
69 | return res
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/search-obs/search-obs.component.html:
--------------------------------------------------------------------------------
1 |
21 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/search-obs/search-obs.component.scss:
--------------------------------------------------------------------------------
1 | .obs-search {
2 | max-height: 600px;
3 | overflow-y: scroll;
4 |
5 | .obs-search-filter {
6 | input:focus {
7 | outline: none;
8 | }
9 | background-color: rgb(231, 231, 231);
10 | display: grid;
11 | grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
12 | gap: 20px;
13 | padding: 5px;
14 | margin-bottom: 5px;
15 |
16 | h2 {
17 | margin: 10px;
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/search-obs/search-obs.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing";
2 | import { FilterObsPipe } from "../filter.pipe";
3 |
4 | import { SearchObsComponent } from "./search-obs.component";
5 |
6 | describe("SearchObsComponent", () => {
7 | let component: SearchObsComponent;
8 | let fixture: ComponentFixture;
9 |
10 | beforeEach(async () => {
11 | await TestBed.configureTestingModule({
12 | declarations: [SearchObsComponent, FilterObsPipe],
13 | }).compileComponents();
14 |
15 | fixture = TestBed.createComponent(SearchObsComponent);
16 | component = fixture.componentInstance;
17 | fixture.detectChanges();
18 | });
19 |
20 | it("should create", () => {
21 | expect(component).toBeTruthy();
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/search-obs/search-obs.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | Input,
4 | ChangeDetectionStrategy,
5 | } from "@angular/core"
6 | import { Observation } from "src/proto/modron_pb"
7 |
8 | @Component({
9 | selector: "app-search-obs",
10 | templateUrl: "./search-obs.component.html",
11 | styleUrls: ["./search-obs.component.scss"],
12 | changeDetection: ChangeDetectionStrategy.OnPush,
13 | })
14 | export class SearchObsComponent {
15 | searchResource = "";
16 | searchObservedVal = "";
17 | searchGroup = "";
18 |
19 | @Input() obs: Observation[] = [];
20 |
21 | applyFilter(event: Event): string {
22 | return (event.target as HTMLInputElement).value
23 | }
24 |
25 | identity(index: number, item: Observation): string {
26 | return item.getUid()
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/severity-indicator/severity-indicator.component.html:
--------------------------------------------------------------------------------
1 |
16 |
17 | {{ getIcon() }}
18 |
19 |
20 | {{ count! | severityAmount }}
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/severity-indicator/severity-indicator.component.scss:
--------------------------------------------------------------------------------
1 |
2 | .severity-circle {
3 | color: #FFF;
4 | display: block;
5 | width: 30px;
6 | height: 30px;
7 | line-height: 30px;
8 | font-size: 16px;
9 | overflow: hidden;
10 | text-align: center;
11 | border-radius: 30px;
12 | margin-right: 6px;
13 | }
14 |
15 | .severity-circle-unknown {
16 | background-color: #9E9E9E;
17 | }
18 |
19 | .severity-circle-info {
20 | background-color: #2196F3;
21 | }
22 |
23 | .severity-circle-low {
24 | background-color: #AFB42B;
25 | }
26 |
27 |
28 | .severity-circle-medium {
29 | background-color: #F9A825;
30 | }
31 |
32 | .severity-circle-high {
33 | background-color: #E65100;
34 | }
35 |
36 | .severity-circle-critical {
37 | background-color: #D50000;
38 |
39 | // Make this blink too!
40 | animation: blinker 1s linear infinite;
41 | }
42 |
43 | @keyframes blinker {
44 | 50% {
45 | background-color: #ff0000;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/severity-indicator/severity-indicator.component.ts:
--------------------------------------------------------------------------------
1 | import {Component, Input} from "@angular/core";
2 | import {Severity} from "../../proto/modron_pb";
3 |
4 | @Component({
5 | selector: "app-severity-indicator",
6 | templateUrl: "./severity-indicator.component.html",
7 | styleUrls: ["./severity-indicator.component.scss"],
8 | })
9 | export class SeverityIndicatorComponent {
10 | @Input()
11 | severity: Severity = Severity.SEVERITY_UNKNOWN;
12 |
13 | @Input()
14 | count: number | undefined;
15 |
16 | getIcon(): string {
17 | switch (this.severity) {
18 | case Severity.SEVERITY_CRITICAL:
19 | return "C";
20 | case Severity.SEVERITY_HIGH:
21 | return "H";
22 | case Severity.SEVERITY_MEDIUM:
23 | return "M";
24 | case Severity.SEVERITY_LOW:
25 | return "L";
26 | case Severity.SEVERITY_INFO:
27 | return "I";
28 | default:
29 | return "?";
30 | }
31 | }
32 |
33 | constructor() {}
34 |
35 | protected readonly Severity = Severity;
36 | }
37 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/severity-indicator/severity-indicator.pipe.ts:
--------------------------------------------------------------------------------
1 | import {Pipe, PipeTransform} from "@angular/core";
2 | import {Impact, Severity} from "../../proto/modron_pb";
3 |
4 | @Pipe({name: "severityName"})
5 | export class SeverityNamePipe implements PipeTransform {
6 | transform(severity: number): string {
7 | switch(severity) {
8 | case Severity.SEVERITY_CRITICAL:
9 | return "Critical";
10 | case Severity.SEVERITY_HIGH:
11 | return "High";
12 | case Severity.SEVERITY_MEDIUM:
13 | return "Medium";
14 | case Severity.SEVERITY_LOW:
15 | return "Low";
16 | case Severity.SEVERITY_INFO:
17 | return "Info";
18 | default:
19 | return "Unknown";
20 | }
21 | }
22 | }
23 |
24 | @Pipe({name: "impactName"})
25 | export class ImpactNamePipe implements PipeTransform {
26 | transform(impact: number): string {
27 | switch(impact) {
28 | case Impact.IMPACT_HIGH:
29 | return "High";
30 | case Impact.IMPACT_MEDIUM:
31 | return "Medium";
32 | case Impact.IMPACT_LOW:
33 | return "Low";
34 | default:
35 | return "Unknown";
36 | }
37 | }
38 | }
39 |
40 | @Pipe({name: "severityAmount"})
41 | export class SeverityAmountPipe implements PipeTransform {
42 | transform(count: number): string {
43 | if(count > 99) {
44 | return "99+";
45 | }
46 | return count.toString();
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/sidenav/sidenav.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
13 |
14 |
19 |
24 |
25 | {{ item.name }}
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/sidenav/sidenav.component.scss:
--------------------------------------------------------------------------------
1 | @use '../../colors.scss' as colors;
2 |
3 | $navbarWidth: 68px;
4 |
5 | .sidenav {
6 | display: flex;
7 | background-color: colors.$sideNavBackground;
8 | flex-direction: column;
9 | width: $navbarWidth;
10 | height: 100%;
11 |
12 | div.top-spacer {
13 | height: 20px;
14 | }
15 |
16 | .menu-icon-container {
17 | align-self: center;
18 | height: 24px;
19 | margin-top: 12px;
20 | margin-bottom: 12px;
21 | }
22 |
23 | div.nav-items {
24 | display: flex;
25 | flex-direction: column;
26 | align-items: center;
27 | width: 100%;
28 | gap: 24px;
29 | margin-top: 20px;
30 |
31 | .nav-item {
32 | cursor: pointer;
33 | text-align: center;
34 | width: $navbarWidth;
35 | overflow: hidden;
36 |
37 | &.active {
38 | .nav-item-icon {
39 | background-color: colors.$sideNavButtonActiveBackground;
40 | }
41 | }
42 |
43 | .nav-item-icon {
44 | font-size: 20px;
45 | line-height: 20px;
46 | height: 20px;
47 | width: 30px;
48 | padding: 4px;
49 | border-radius: 40px;
50 | transition: 250ms;
51 | &:hover {
52 | background-color: colors.$sideNavButtonActiveBackground;
53 | }
54 | }
55 |
56 | .nav-item-text {
57 | width: $navbarWidth;
58 | font-weight: bold;
59 | font-size: 12px;
60 | text-align: center;
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/sidenav/sidenav.component.spec.ts:
--------------------------------------------------------------------------------
1 | import {ComponentFixture, TestBed} from "@angular/core/testing";
2 | import {SidenavComponent} from "./sidenav.component";
3 | import {Router} from "@angular/router";
4 | import {RouterTestingModule} from "@angular/router/testing";
5 | import {Component} from "@angular/core";
6 |
7 | @Component({
8 | template: ""
9 | })
10 | class DummyComponent {
11 | }
12 |
13 | describe("SidenavComponent", () => {
14 | let component: SidenavComponent;
15 | let fixture: ComponentFixture;
16 | let router: Router;
17 |
18 | beforeEach(async () => {
19 | const testingModule = TestBed.configureTestingModule({
20 | imports: [SidenavComponent, RouterTestingModule.withRoutes(
21 | [{path: "modron/resourcegroups", component: DummyComponent}]
22 | )],
23 | providers: []
24 | })
25 | await testingModule.compileComponents();
26 |
27 | fixture = TestBed.createComponent(SidenavComponent);
28 | component = fixture.componentInstance;
29 | router = TestBed.inject(Router);
30 | fixture.detectChanges();
31 | });
32 |
33 | it("should create", () => {
34 | expect(component).toBeTruthy();
35 | });
36 |
37 | it("should mark the icon as active when the route matches", async () => {
38 | await router.navigateByUrl("/modron/resourcegroups");
39 | fixture.detectChanges();
40 | const {debugElement} = fixture;
41 | const navItems = debugElement.nativeElement.querySelectorAll("div.nav-items div.nav-item")
42 | expect(navItems.length).toBe(3);
43 | expect(navItems[0].classList).toContain("active");
44 | expect(navItems[1].classList).not.toContain("active");
45 | expect(navItems[2].classList).not.toContain("active");
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/sidenav/sidenav.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from "@angular/core";
2 | import { CommonModule } from "@angular/common";
3 | import {MatIconModule} from "@angular/material/icon";
4 | import {MatListModule} from "@angular/material/list";
5 | import {Router, RouterLink} from "@angular/router";
6 | import {MatRippleModule} from "@angular/material/core";
7 |
8 | @Component({
9 | selector: "app-sidenav",
10 | standalone: true,
11 | imports: [CommonModule, MatIconModule, MatListModule, RouterLink, MatRippleModule],
12 | templateUrl: "./sidenav.component.html",
13 | styleUrl: "./sidenav.component.scss"
14 | })
15 | export class SidenavComponent {
16 | constructor(public router: Router) {}
17 |
18 | public navItems = [
19 | { link: "/modron/resourcegroups", name: "Resource Groups", icon: "folder" },
20 | { link: "/modron/stats", name: "Stats", icon: "bar_chart" },
21 | { link: "/modron/exceptions", name: "Exceptions", icon: "notifications_paused" },
22 | ];
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/state/authentication.store.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core";
2 | import { User, AuthenticationService } from "../authentication.service";
3 |
4 | @Injectable()
5 | export class AuthenticationStore {
6 | private _user = new User(false);
7 |
8 | constructor(private _service: AuthenticationService) {
9 | this.fetchInitialData();
10 | }
11 |
12 | get user(): User {
13 | return this._user;
14 | }
15 |
16 | private fetchInitialData() {
17 | this._user = this._service.authenticate();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/state/notification.store.ts:
--------------------------------------------------------------------------------
1 | import { NotificationService } from "../notification.service"
2 | import { Injectable } from "@angular/core"
3 | import { BehaviorSubject, map, Observable } from "rxjs"
4 | import { NotificationException } from "../model/notification.model"
5 | import { AuthenticationStore } from "./authentication.store"
6 |
7 | @Injectable()
8 | export class NotificationStore {
9 | private _exceptions: BehaviorSubject
10 |
11 | constructor(
12 | private _service: NotificationService,
13 | private _auth: AuthenticationStore
14 | ) {
15 | this._exceptions = new BehaviorSubject([])
16 | this.fetchInitialData()
17 | }
18 |
19 | get exceptions$(): Observable {
20 | return new Observable((sub) => this._exceptions.subscribe(sub))
21 | }
22 |
23 | get exceptions(): NotificationException[] {
24 | return this._exceptions.value
25 | }
26 |
27 | createException$(
28 | exp: NotificationException
29 | ): Observable {
30 | return this._service.createException$(exp.toProto()).pipe(
31 | map((proto) => {
32 | const exp = NotificationException.fromProto(proto)
33 | this._exceptions.next(this._exceptions.getValue().concat([exp]))
34 | return exp
35 | })
36 | )
37 | }
38 |
39 | private listExceptions(userEmail: string) {
40 | this._service.listExceptions$(userEmail).subscribe((protos) => {
41 | this._exceptions.next(
42 | protos.map((proto) => NotificationException.fromProto(proto))
43 | )
44 | })
45 | }
46 |
47 | private fetchInitialData() {
48 | this.listExceptions(this._auth.user?.email ?? "")
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/stats.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestBed } from "@angular/core/testing";
2 |
3 | import { StatsService } from "./stats.service";
4 |
5 | describe("StatsService", () => {
6 | let service: StatsService;
7 |
8 | beforeEach(() => {
9 | TestBed.configureTestingModule({});
10 | service = TestBed.inject(StatsService);
11 | });
12 |
13 | it("should be created", () => {
14 | expect(service).toBeTruthy();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/stats.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from "@angular/core"
2 | import { ModronService } from "./modron.service"
3 | import { Observation } from "src/proto/modron_pb"
4 |
5 | @Injectable({
6 | providedIn: "root",
7 | })
8 | export class StatsService {
9 | constructor(public modron: ModronService) { }
10 |
11 | getObservationsPerType(): Map {
12 | throw Error("unimplemented")
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/stats/stats.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { ComponentFixture, TestBed } from "@angular/core/testing"
2 | import { ActivatedRoute } from "@angular/router"
3 | import { reverseSortPipe } from "../filter.pipe"
4 | import {
5 | MapByTypePipe,
6 | mapFlatRulesPipe,
7 | } from "../resource-group-details/resource-group-details.pipe"
8 | import {
9 | InvalidProjectNb,
10 | ObservationsPipe,
11 | } from "../resource-groups/resource-groups.pipe"
12 | import { ModronStore } from "../state/modron.store"
13 |
14 | import { StatsComponent } from "./stats.component"
15 |
16 | describe("StatsComponent", () => {
17 | let component: StatsComponent
18 | let fixture: ComponentFixture
19 |
20 | beforeEach(async () => {
21 | await TestBed.configureTestingModule({
22 | declarations: [
23 | StatsComponent,
24 | InvalidProjectNb,
25 | ObservationsPipe,
26 | mapFlatRulesPipe,
27 | reverseSortPipe,
28 | MapByTypePipe,
29 | ],
30 | providers: [
31 | ModronStore,
32 | {
33 | provide: ActivatedRoute,
34 | useValue: {
35 | snapshot: {
36 | paramMap: {
37 | get(): string {
38 | return "mock-observation-id"
39 | },
40 | },
41 | },
42 | },
43 | },
44 | ],
45 | }).compileComponents()
46 |
47 | fixture = TestBed.createComponent(StatsComponent)
48 | component = fixture.componentInstance
49 | fixture.detectChanges()
50 | })
51 |
52 | it("should create", () => {
53 | expect(component).toBeTruthy()
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/token.interceptor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HttpEvent,
3 | HttpHandler,
4 | HttpInterceptor,
5 | HttpRequest,
6 | } from "@angular/common/http"
7 | import { Injectable } from "@angular/core"
8 | import { Observable } from "rxjs"
9 | import { AuthenticationService } from "./authentication.service"
10 |
11 | @Injectable()
12 | export class TokenInterceptor implements HttpInterceptor {
13 | constructor(public auth: AuthenticationService) { }
14 |
15 | // Using any here as we implement HttpInterceptor: https://angular.io/api/common/http/HttpInterceptor
16 | intercept(
17 | request: HttpRequest,
18 | next: HttpHandler
19 | ): Observable> {
20 | request = request.clone({
21 | setHeaders: {
22 | Authorization: `Bearer ${this.auth.tokenId}`,
23 | },
24 | })
25 | return next.handle(request)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/ui-demo/ui-demo.component.html:
--------------------------------------------------------------------------------
1 |
2 |
UI Demo
3 |
Severities
4 |
Empty
5 |
11 |
12 |
With number
13 |
20 |
21 |
Card
22 |
35 |
36 |
Single Observation (old)
37 |
40 |
41 |
Observation Dialog
42 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/ui-demo/ui-demo.component.scss:
--------------------------------------------------------------------------------
1 | .severity-container {
2 | display: grid;
3 | grid-template-columns: repeat(8, 50px);
4 | row-gap: 10px;
5 | }
6 |
7 | .rg-container {
8 | display: grid;
9 | padding: 10px;
10 | grid-template-columns: repeat(4, 300px);
11 | column-gap: 10px;
12 | row-gap: 10px;
13 | }
14 |
15 | .observation-dialog {
16 | width: 800px;
17 | border: 1px solid #333;
18 | padding: 10px;
19 | border-radius: 5px;
20 | margin: 16px;
21 | }
22 |
--------------------------------------------------------------------------------
/src/ui/client/src/app/ui-demo/ui-demo.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component} from "@angular/core";
2 | import {Impact, Observation, Remediation, Severity} from "../../proto/modron_pb";
3 | import Category = Observation.Category;
4 |
5 | @Component({
6 | selector: "app-ui-demo",
7 | templateUrl: "./ui-demo.component.html",
8 | styleUrls: ["./ui-demo.component.scss"],
9 | changeDetection: ChangeDetectionStrategy.OnPush,
10 | })
11 | export class UIDemoComponent {
12 | protected readonly Severity = Severity;
13 | protected readonly Object = Object;
14 | protected readonly severityValues: Severity[] = Object.values(Severity).reverse() as Severity[];
15 | protected readonly Date = Date;
16 | date: Date | null = new Date();
17 |
18 | public demoObservation = new Observation();
19 | constructor() {
20 | this.demoObservation.setName("EXAMPLE_DEMO_OBSERVATION");
21 | this.demoObservation.setRiskScore(Severity.SEVERITY_CRITICAL);
22 | this.demoObservation.setSeverity(Severity.SEVERITY_HIGH);
23 | this.demoObservation.setImpact(Impact.IMPACT_HIGH);
24 | this.demoObservation.setCategory(Category.CATEGORY_MISCONFIGURATION)
25 | this.demoObservation.setImpactReason("environment=production")
26 | const remediation = new Remediation();
27 | remediation.setDescription("Example description");
28 | remediation.setRecommendation("Example recommendation");
29 | this.demoObservation.setRemediation(remediation);
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/src/ui/client/src/assets/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/assets/.gitkeep
--------------------------------------------------------------------------------
/src/ui/client/src/colors.scss:
--------------------------------------------------------------------------------
1 | @use '@angular/material' as mat;
2 |
3 | $my-primary: mat.m2-define-palette(mat.$m2-indigo-palette, 500);
4 | $my-accent: mat.m2-define-palette(mat.$m2-pink-palette, A200, A100, A400);
5 |
6 | $my-theme: mat.m2-define-light-theme((
7 | color: (
8 | primary: $my-primary,
9 | accent: $my-accent,
10 | ),
11 | density: 0,
12 | ));
13 |
14 | $sideNavBackground: mat.m2-get-color-from-palette($my-primary, default, 0.1);
15 | $sideNavButtonActiveBackground: mat.m2-get-color-from-palette($my-primary, default, 0.2);
16 |
17 | $title: #FFF;
18 | $secondaryText: #555;
19 |
20 | $danger: #da1e28;
21 | $warning: #f5a623;
22 | $allGood: #24a148;
23 |
24 |
--------------------------------------------------------------------------------
/src/ui/client/src/environments/environment.base.ts:
--------------------------------------------------------------------------------
1 | // This file needs to be extended by an environment file (e.g., environment.ts, environment.local.ts, etc.)
2 |
3 | export const environment = {
4 | organization: "",
5 | };
6 |
--------------------------------------------------------------------------------
/src/ui/client/src/environments/environment.local.ts:
--------------------------------------------------------------------------------
1 | // This file can be replaced during build by using the `fileReplacements` array.
2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`.
3 | // The list of file replacements can be found in `angular.json`.
4 |
5 | import { environment as baseEnvironment } from "./environment.base"
6 |
7 | const _environment = {
8 | production: false,
9 | local: true,
10 | }
11 |
12 | export const environment = { ...baseEnvironment, ..._environment }
13 |
14 | /*
15 | * For easier debugging in development mode, you can import the following file
16 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
17 | *
18 | * This import should be commented out in production mode because it will have a negative impact
19 | * on performance if an error is thrown.
20 | */
21 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
22 |
--------------------------------------------------------------------------------
/src/ui/client/src/environments/environment.prod.ts:
--------------------------------------------------------------------------------
1 | // This file can be replaced during build by using the `fileReplacements` array.
2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`.
3 | // The list of file replacements can be found in `angular.json`.
4 |
5 | import { environment as baseEnvironment } from "./environment.base"
6 |
7 | const _environment = {
8 | production: true,
9 | local: false,
10 | }
11 |
12 | export const environment = { ...baseEnvironment, ..._environment }
13 |
14 | /*
15 | * For easier debugging in development mode, you can import the following file
16 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
17 | *
18 | * This import should be commented out in production mode because it will have a negative impact
19 | * on performance if an error is thrown.
20 | */
21 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
22 |
--------------------------------------------------------------------------------
/src/ui/client/src/environments/environment.ts:
--------------------------------------------------------------------------------
1 | // This file can be replaced during build by using the `fileReplacements` array.
2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`.
3 | // The list of file replacements can be found in `angular.json`.
4 |
5 | import { environment as baseEnvironment } from "./environment.base"
6 |
7 | const _environment = {
8 | production: false,
9 | local: false,
10 | }
11 |
12 | export const environment = { ...baseEnvironment, ..._environment }
13 |
14 | /*
15 | * For easier debugging in development mode, you can import the following file
16 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
17 | *
18 | * This import should be commented out in production mode because it will have a negative impact
19 | * on performance if an error is thrown.
20 | */
21 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
22 |
--------------------------------------------------------------------------------
/src/ui/client/src/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/favicon.png
--------------------------------------------------------------------------------
/src/ui/client/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Modron
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/ui/client/src/main.ts:
--------------------------------------------------------------------------------
1 | import { enableProdMode } from "@angular/core";
2 | import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
3 |
4 | import { AppModule } from "./app/app.module";
5 | import { environment } from "./environments/environment";
6 |
7 | if (environment.production) {
8 | enableProdMode();
9 | }
10 |
11 | platformBrowserDynamic().bootstrapModule(AppModule)
12 | .catch(err => console.error(err));
13 |
--------------------------------------------------------------------------------
/src/ui/client/src/styles.scss:
--------------------------------------------------------------------------------
1 | @use '@angular/material' as mat;
2 | @import '@material-symbols/font-400';
3 | @import 'colors.scss';
4 |
5 | /* You can add global styles to this file, and also import other style files */
6 | @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&display=swap');
7 |
8 | @include mat.all-component-themes($my-theme);
9 |
10 | body {
11 | font-family: 'IBM Plex Sans', sans-serif;
12 | margin: 0;
13 | height: 100%
14 | }
15 |
16 | button {
17 | border: none;
18 | cursor: pointer;
19 | }
20 |
21 | button:active {
22 | background-color: rgb(237, 237, 237);
23 | border: none;
24 | cursor: pointer;
25 | }
26 |
27 | input {
28 | border: none;
29 | background-color: rgb(249, 249, 249);
30 | }
31 |
32 | /*
33 | Styles for dynamic elements
34 | */
35 | .mat-column-shortDesc > p{
36 | max-width: 100%;
37 | overflow: hidden;
38 | text-overflow: ellipsis;
39 | }
40 |
41 | .markdown-content {
42 | p {
43 | margin-top: 0;
44 | margin-bottom: 0;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/ui/client/src/test.ts:
--------------------------------------------------------------------------------
1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2 |
3 | import "zone.js/testing"
4 | import { getTestBed } from "@angular/core/testing"
5 | import {
6 | BrowserDynamicTestingModule,
7 | platformBrowserDynamicTesting
8 | } from "@angular/platform-browser-dynamic/testing"
9 |
10 | // First, initialize the Angular testing environment.
11 | getTestBed().initTestEnvironment(
12 | BrowserDynamicTestingModule,
13 | platformBrowserDynamicTesting(),
14 | )
15 |
--------------------------------------------------------------------------------
/src/ui/client/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "extends": "./tsconfig.json",
4 | "compilerOptions": {
5 | "outDir": "./out-tsc/app",
6 | "types": []
7 | },
8 | "files": [
9 | "src/main.ts",
10 | "src/polyfills.ts"
11 | ],
12 | "include": [
13 | "src/**/*.d.ts",
14 | "src/proto/**/*.ts"
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/src/ui/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "compileOnSave": false,
4 | "compilerOptions": {
5 | "baseUrl": "./",
6 | "outDir": "./dist/out-tsc",
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "noImplicitOverride": true,
10 | "noPropertyAccessFromIndexSignature": true,
11 | "noImplicitReturns": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "sourceMap": true,
14 | "declaration": false,
15 | "downlevelIteration": true,
16 | "experimentalDecorators": true,
17 | "moduleResolution": "node",
18 | "importHelpers": true,
19 | "target": "ES2022",
20 | "module": "ES2022",
21 | "lib": [
22 | "es2020",
23 | "dom"
24 | ],
25 | },
26 | "angularCompilerOptions": {
27 | "enableI18nLegacyMessageIdFormat": false,
28 | "strictInjectionParameters": true,
29 | "strictInputAccessModifiers": true,
30 | "strictTemplates": true
31 | },
32 | "exclude": [
33 | "cypress.config.ts"
34 | ],
35 | "files": [
36 | "cypress.config.ts"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/src/ui/client/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "extends": "./tsconfig.json",
4 | "compilerOptions": {
5 | "outDir": "./out-tsc/spec",
6 | "types": [
7 | "jasmine"
8 | ]
9 | },
10 | "files": [
11 | "src/test.ts",
12 | "src/polyfills.ts"
13 | ],
14 | "include": [
15 | "src/**/*.spec.ts",
16 | "src/**/*.d.ts"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/src/ui/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | mock-grpc-server:
4 | build:
5 | context: mock-grpc-server
6 | networks:
7 | - envoy-net
8 | envoy:
9 | image: envoyproxy/envoy:v1.29-latest
10 | volumes:
11 | - ./mock-grpc-server/envoy.yaml:/etc/envoy/envoy.yaml:ro
12 | ports:
13 | - "4201:4201"
14 | networks:
15 | - envoy-net
16 | networks:
17 | envoy-net:
18 |
--------------------------------------------------------------------------------
/src/ui/go.mod:
--------------------------------------------------------------------------------
1 | module server
2 |
3 | go 1.23.2
4 |
5 | require (
6 | github.com/golang/glog v1.2.2
7 | google.golang.org/api v0.203.0
8 | )
9 |
10 | require (
11 | cloud.google.com/go/auth v0.10.0 // indirect
12 | cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect
13 | cloud.google.com/go/compute/metadata v0.5.2 // indirect
14 | github.com/felixge/httpsnoop v1.0.4 // indirect
15 | github.com/go-logr/logr v1.4.2 // indirect
16 | github.com/go-logr/stdr v1.2.2 // indirect
17 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
18 | github.com/golang/protobuf v1.5.4 // indirect
19 | github.com/google/s2a-go v0.1.8 // indirect
20 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
21 | github.com/googleapis/gax-go/v2 v2.13.0 // indirect
22 | go.opencensus.io v0.24.0 // indirect
23 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
24 | go.opentelemetry.io/otel v1.31.0 // indirect
25 | go.opentelemetry.io/otel/metric v1.31.0 // indirect
26 | go.opentelemetry.io/otel/trace v1.31.0 // indirect
27 | golang.org/x/crypto v0.28.0 // indirect
28 | golang.org/x/net v0.30.0 // indirect
29 | golang.org/x/oauth2 v0.23.0 // indirect
30 | golang.org/x/sys v0.26.0 // indirect
31 | golang.org/x/text v0.19.0 // indirect
32 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect
33 | google.golang.org/grpc v1.67.1 // indirect
34 | google.golang.org/protobuf v1.35.1 // indirect
35 | )
36 |
--------------------------------------------------------------------------------
/src/ui/mock-grpc-server/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/src/ui/mock-grpc-server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts
2 | COPY . /app
3 | WORKDIR /app
4 | RUN npm install
5 | ENV NODE_OPTIONS='--loader ts-node/esm'
6 | ENTRYPOINT ["node", "server.ts"]
7 |
--------------------------------------------------------------------------------
/src/ui/mock-grpc-server/copy-proto.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 | # Unfortunately Docker doesn't support symlinks, so we need to copy the proto files
4 |
5 | # Make sure we're running in the script directory
6 | pushd "$(dirname "$0")"
7 | cp ../../proto/*.proto proto/
8 | popd
9 |
10 |
--------------------------------------------------------------------------------
/src/ui/mock-grpc-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "modron-mock-server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "type": "module",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "dev": "NODE_OPTIONS='--loader ts-node/esm' npx nodemon -- server.ts",
9 | "genproto": "./generate_proto.sh"
10 | },
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "@alenon/grpc-mock-server": "^3.0.21",
15 | "wait-for-sigint": "^0.1.0",
16 | "nodemon": "^3.0.1",
17 | "ts-node": "^10.9.1",
18 | "@grpc/grpc-js": "^1.8",
19 | "@improbable-eng/grpc-web": "^0.15.0",
20 | "@types/google-protobuf": "^3.15.6",
21 | "google-protobuf": "^3.21.2"
22 | },
23 | "devDependencies": {
24 | "ts-protoc-gen": "^0.15.0"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/ui/mock-grpc-server/proto/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/mock-grpc-server/proto/.gitkeep
--------------------------------------------------------------------------------
/src/ui/mock-grpc-server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "esModuleInterop": true,
4 | "module": "ES2022",
5 | "moduleResolution": "node16",
6 | "target": "ES2022",
7 | },
8 | "include": [
9 | "/**/*.ts"
10 | ],
11 | "exclude": [
12 | "node_modules"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/src/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ui",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "postinstall": "(cd client && npm install); (cd mock-grpc-server && npm install)",
6 | "dev": "concurrently --kill-others \"npm run dev:client\" \"docker-compose up -d\"",
7 | "dev:client": "cd client/ && npm run ng -- serve --verbose --proxy-config ../proxy.conf.json",
8 | "dev:mock-grpc-server-envoy": "cd mock-grpc-server/ && docker run --rm -p4201:4201 -p9901:9901 -v $(pwd)/envoy.yaml:/etc/envoy/envoy.yaml:ro -t envoyproxy/envoy:v1.24-latest",
9 | "dev:mock-grpc-server": "npm run --prefix mock-grpc-server/ dev"
10 | },
11 | "dependencies": {
12 | "concurrently": "^8.2.2"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/ui/proxy.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "/ModronService/*": {
3 | "target": "http://localhost:4201",
4 | "secure": false,
5 | "changeOrigin": true,
6 | "logLevel": "debug"
7 | },
8 | "/NotificationService/*": {
9 | "target": "http://localhost:4201",
10 | "secure": false,
11 | "changeOrigin": true,
12 | "logLevel": "debug"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/gke.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | )
7 |
8 | func GetGKEReference(resourceLink string) (projectID string, location string, clusterName string, namespace string) {
9 | // resourceLink is formatted as follows:
10 | // //container.googleapis.com/projects/project-id/zones/us-central1-b/clusters/gke-cluster-name/k8s/namespaces/kubernetes-ns-name
11 | split := strings.Split(resourceLink, "/")
12 | if len(split) != 12 { //nolint:mnd
13 | return "", "", "", ""
14 | }
15 | projectID = split[4]
16 | location = split[6]
17 | clusterName = split[8]
18 | namespace = split[11]
19 | return
20 | }
21 |
22 | func GetGkePodLink(name string, parent string) string {
23 | // name is like "my-pod-name"
24 | // parent is "//container.googleapis.com/projects/project-id/zones/us-central1-b/clusters/gke-cluster-name/k8s/namespaces/kubernetes-ns-name"
25 | projectID, location, clusterName, namespace := GetGKEReference(parent)
26 | return fmt.Sprintf("https://console.cloud.google.com/kubernetes/pod/%s/%s/%s/%s/details?project=%s",
27 | location,
28 | clusterName,
29 | namespace,
30 | name,
31 | projectID,
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/utils/gke_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "testing"
4 |
5 | func TestGetGKEReference(t *testing.T) {
6 | tc := [][]string{
7 | {
8 | "//container.googleapis.com/projects/project-id/zones/us-central1-b/clusters/gke-cluster-name/k8s/namespaces/kubernetes-ns-name",
9 | "project-id",
10 | "us-central1-b",
11 | "gke-cluster-name",
12 | "kubernetes-ns-name",
13 | },
14 | }
15 |
16 | for _, tt := range tc {
17 | projectID, location, clusterName, namespace := GetGKEReference(tt[0])
18 | if projectID != tt[1] {
19 | t.Errorf("expected projectID %s, got %s", tt[1], projectID)
20 | }
21 | if location != tt[2] {
22 | t.Errorf("expected location %s, got %s", tt[2], location)
23 | }
24 | if clusterName != tt[3] {
25 | t.Errorf("expected clusterName %s, got %s", tt[3], clusterName)
26 | }
27 | if namespace != tt[4] {
28 | t.Errorf("expected namespace %s, got %s", tt[4], namespace)
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/utils/groups.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "golang.org/x/exp/maps"
5 |
6 | pb "github.com/nianticlabs/modron/src/proto/generated"
7 | )
8 |
9 | func GroupsFromResources(resources []*pb.Resource) (allGroups []string) {
10 | resourceGroups := map[string]struct{}{}
11 | for _, r := range resources {
12 | resourceGroups[r.ResourceGroupName] = struct{}{}
13 | }
14 | return maps.Keys(resourceGroups)
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/hierarchy.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 |
6 | pb "github.com/nianticlabs/modron/src/proto/generated"
7 | )
8 |
9 | func ComputeRgHierarchy(resources []*pb.Resource) (map[string]*pb.RecursiveResource, error) {
10 | resourceMap := make(map[string]*pb.RecursiveResource)
11 | for _, r := range resources {
12 | if r.GetResourceGroup() == nil {
13 | continue
14 | }
15 | recRes, err := ToRecursiveResource(r)
16 | if err != nil {
17 | return nil, err
18 | }
19 | resourceMap[r.Name] = recRes
20 | }
21 |
22 | for _, r := range resources {
23 | if r.Parent == "" {
24 | var err error
25 | resourceMap[""], err = ToRecursiveResource(r)
26 | if err != nil {
27 | return nil, err
28 | }
29 | continue
30 | }
31 | parent, ok := resourceMap[r.Parent]
32 | if !ok {
33 | log.Warnf("parent %q not found", r.Parent)
34 | if r.ResourceGroupName == "" {
35 | log.Errorf("resource %q has no parent and no resource group", r.Name)
36 | continue
37 | }
38 | if r.ResourceGroupName == r.Name {
39 | log.Errorf("resource %q is its own parent", r.Name)
40 | continue
41 | }
42 | parent, ok = resourceMap[r.ResourceGroupName]
43 | if !ok {
44 | log.Errorf("resource group %q not found, is %q orphan?", r.ResourceGroupName, r.Name)
45 | continue
46 | }
47 | }
48 | recRes, err := ToRecursiveResource(r)
49 | if err != nil {
50 | return nil, fmt.Errorf("toRecursiveResource: %w", err)
51 | }
52 | parent.Children = append(parent.Children, recRes)
53 | resourceMap[r.Parent] = parent
54 | }
55 | return resourceMap, nil
56 | }
57 |
58 | func ToRecursiveResource(r *pb.Resource) (*pb.RecursiveResource, error) {
59 | t, err := TypeFromResource(r)
60 | if err != nil {
61 | return nil, fmt.Errorf("typeFromResourceAsString: %w", err)
62 | }
63 | return &pb.RecursiveResource{
64 | Uuid: r.Uid,
65 | Name: r.Name,
66 | DisplayName: r.DisplayName,
67 | Parent: r.Parent,
68 | Type: t,
69 | Labels: r.Labels,
70 | Tags: r.Tags,
71 | }, nil
72 | }
73 |
--------------------------------------------------------------------------------
/src/utils/keys.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/sirupsen/logrus"
7 |
8 | "github.com/nianticlabs/modron/src/constants"
9 | )
10 |
11 | var log = logrus.StandardLogger().WithField(constants.LogKeyPkg, "utils")
12 |
13 | const keyParts = 6
14 |
15 | // GetKeyID converts a key reference (projects/my-project/serviceAccounts/sa-1/keys/abc) to a key ID (abc).
16 | func GetKeyID(keyRef string) string {
17 | if !strings.HasPrefix(keyRef, constants.GCPProjectsNamePrefix) {
18 | log.Errorf("keyRef %s does not start with %s", keyRef, constants.GCPProjectsNamePrefix)
19 | return keyRef
20 | }
21 |
22 | split := strings.Split(keyRef, "/")
23 | if len(split) < keyParts {
24 | log.Errorf("keyRef %s has less than 6 parts", keyRef)
25 | return keyRef
26 | }
27 |
28 | return split[5]
29 | }
30 |
31 | func GetServiceAccountNameFromKeyRef(keyRef string) string {
32 | if !strings.HasPrefix(keyRef, constants.GCPProjectsNamePrefix) {
33 | log.Errorf("keyRef %s does not start with %s", keyRef, constants.GCPProjectsNamePrefix)
34 | return keyRef
35 | }
36 |
37 | split := strings.Split(keyRef, "/")
38 | if len(split) < keyParts {
39 | log.Errorf("keyRef %s has less than 6 parts", keyRef)
40 | return keyRef
41 | }
42 |
43 | return split[3]
44 | }
45 |
--------------------------------------------------------------------------------
/src/utils/keys_test.go:
--------------------------------------------------------------------------------
1 | package utils_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/nianticlabs/modron/src/utils"
7 | )
8 |
9 | func TestGetKeyID(t *testing.T) {
10 | tests := []struct {
11 | name string
12 | keyRef string
13 | expected string
14 | }{
15 | {
16 | name: "valid key reference",
17 | keyRef: "projects/my-project/serviceAccounts/sa-1/keys/abc",
18 | expected: "abc",
19 | },
20 | {
21 | name: "invalid key reference",
22 | keyRef: "invalid-key-reference",
23 | expected: "invalid-key-reference",
24 | },
25 | {
26 | name: "key reference with less than 6 parts",
27 | keyRef: "projects/my-project/serviceAccounts/sa-1/keys",
28 | expected: "projects/my-project/serviceAccounts/sa-1/keys",
29 | },
30 | }
31 | for _, tt := range tests {
32 | t.Run(tt.name, func(t *testing.T) {
33 | if got := utils.GetKeyID(tt.keyRef); got != tt.expected {
34 | t.Errorf("GetKeyID() = %v, want %v", got, tt.expected)
35 | }
36 | })
37 | }
38 | }
39 |
40 | func TestGetServiceAccountNameFromKeyRef(t *testing.T) {
41 | tests := []struct {
42 | name string
43 | keyRef string
44 | expected string
45 | }{
46 | {
47 | name: "valid key reference",
48 | keyRef: "projects/my-project/serviceAccounts/sa-1/keys/abc",
49 | expected: "sa-1",
50 | },
51 | {
52 | name: "invalid key reference",
53 | keyRef: "invalid-key-reference",
54 | expected: "invalid-key-reference",
55 | },
56 | {
57 | name: "key reference with less than 6 parts",
58 | keyRef: "projects/my-project/serviceAccounts/sa-1/keys",
59 | expected: "projects/my-project/serviceAccounts/sa-1/keys",
60 | },
61 | }
62 | for _, tt := range tests {
63 | t.Run(tt.name, func(t *testing.T) {
64 | if got := utils.GetServiceAccountNameFromKeyRef(tt.keyRef); got != tt.expected {
65 | t.Errorf("GetServiceAccountNameFromKeyRef() = %v, want %v", got, tt.expected)
66 | }
67 | })
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/utils/name.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "strings"
4 |
5 | const (
6 | containerAPI = "//container.googleapis.com/"
7 | iamAPI = "//iam.googleapis.com/"
8 | computeAPI = "//compute.googleapis.com/"
9 | )
10 |
11 | // GetHumanReadableName returns the human-readable name of the resource - we currently use an allow-list of APIs
12 | // to avoid interpreting the resource name incorrectly.
13 | func GetHumanReadableName(resourceLink string) string {
14 | for _, p := range []string{containerAPI, iamAPI, computeAPI} {
15 | if strings.HasPrefix(resourceLink, p) {
16 | r := strings.TrimPrefix(resourceLink, p)
17 | split := strings.Split(r, "/")
18 | return split[len(split)-1]
19 | }
20 | }
21 | return resourceLink
22 | }
23 |
24 | func StripProjectsPrefix(prefixedProject string) string {
25 | return strings.TrimPrefix(prefixedProject, "projects/")
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/name_test.go:
--------------------------------------------------------------------------------
1 | package utils_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/google/go-cmp/cmp"
7 |
8 | "github.com/nianticlabs/modron/src/utils"
9 | )
10 |
11 | func TestGetHumanReadableName(t *testing.T) {
12 | tc := [][]string{
13 | {
14 | "//container.googleapis.com/projects/xyz/locations/us-central1/clusters/cluster-name/k8s/namespaces/kube-system/pods/my-pod-1",
15 | "my-pod-1",
16 | },
17 | {
18 | "//container.googleapis.com/projects/xyz/locations/us-central1/clusters/cluster-name/k8s/namespaces/kube-system",
19 | "kube-system",
20 | },
21 | {
22 | "//container.googleapis.com/projects/xyz/locations/us-central1/clusters/cluster-name",
23 | "cluster-name",
24 | },
25 | {
26 | "//container.googleapis.com/projects/xyz/locations/us-central1",
27 | "us-central1",
28 | },
29 | {
30 | "//container.googleapis.com/projects/xyz",
31 | "xyz",
32 | },
33 | {
34 | "//iam.googleapis.com/projects/example-project/serviceAccounts/my-service-account@example-project.iam.gserviceaccount.com",
35 | "my-service-account@example-project.iam.gserviceaccount.com",
36 | },
37 | {
38 | "//iam.googleapis.com/projects/example-project/serviceAccounts/3984989392373/keys/b8ceb3f5d69d4e46acc9e74bf224d4e9",
39 | "b8ceb3f5d69d4e46acc9e74bf224d4e9",
40 | },
41 | {
42 | "//compute.googleapis.com/projects/example-project/zones/us-central1-f/instances/my-instance-4897322-03032024-cxx1-test-a0b0c0",
43 | "my-instance-4897322-03032024-cxx1-test-a0b0c0",
44 | },
45 | {
46 | "//container.googleapis.com/projects/example-1/zones/us-central1-b/clusters/security-runners/k8s/namespaces/twistlock",
47 | "twistlock",
48 | },
49 | {
50 | "//container.googleapis.com/projects/example-2/zones/us-central1-b/clusters/security-runners",
51 | "security-runners",
52 | },
53 | }
54 |
55 | for _, c := range tc {
56 | want := c[1]
57 | got := utils.GetHumanReadableName(c[0])
58 | if diff := cmp.Diff(want, got); diff != "" {
59 | t.Errorf("GetHumanReadableName(%q) mismatch (-want +got):\n%s", c[0], diff)
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/utils/protobuf.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 |
6 | "google.golang.org/protobuf/proto"
7 |
8 | pb "github.com/nianticlabs/modron/src/proto/generated"
9 | )
10 |
11 | func TypeFromResource(rsrc *pb.Resource) (ty string, err error) {
12 | if rsrc == nil {
13 | return "", fmt.Errorf("resource must not be nil")
14 | }
15 | reflectMsg := rsrc.ProtoReflect()
16 | if reflectMsg == nil {
17 | return "", fmt.Errorf("ProtoReflect() returned nil")
18 | }
19 | typeField := reflectMsg.Descriptor().Oneofs().ByName("type")
20 | if typeField == nil {
21 | return "", fmt.Errorf("cannot find field \"type\"")
22 | }
23 | field := reflectMsg.WhichOneof(typeField)
24 | if field == nil {
25 | return "", fmt.Errorf("cannot find field in oneof")
26 | }
27 | fieldMessage := reflectMsg.Get(field).Message()
28 | if fieldMessage == nil {
29 | return "", fmt.Errorf("field message is nil")
30 | }
31 | ty = string(fieldMessage.Descriptor().FullName())
32 | return
33 | }
34 |
35 | func ProtoAcceptsTypes(types []proto.Message) (res []string) {
36 | for _, t := range types {
37 | res = append(res, string(t.ProtoReflect().Descriptor().FullName()))
38 | }
39 | return
40 | }
41 |
--------------------------------------------------------------------------------
/src/utils/ref.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | func RefOrNull(s string) *string {
4 | if s == "" {
5 | return nil
6 | }
7 |
8 | return &s
9 | }
10 |
--------------------------------------------------------------------------------
/src/utils/resource_ref.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import pb "github.com/nianticlabs/modron/src/proto/generated"
4 |
5 | func GetResourceRef(rsrc *pb.Resource) *pb.ResourceRef {
6 | if rsrc == nil {
7 | return nil
8 | }
9 | return &pb.ResourceRef{
10 | Uid: &rsrc.Uid,
11 | GroupName: rsrc.ResourceGroupName,
12 | ExternalId: RefOrNull(rsrc.Name),
13 | CloudPlatform: pb.CloudPlatform_GCP, // TODO: Change when we have more cloud platforms
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/rule.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 |
7 | "github.com/nianticlabs/modron/src/model"
8 | )
9 |
10 | func GetRuleConfig[T any](ctx context.Context, e model.Engine, name string, c *T) error {
11 | v, err := e.GetRuleConfig(ctx, name)
12 | if err != nil {
13 | log.Errorf("no config found for rule %q: %v", name, err)
14 | return err
15 | }
16 | return json.Unmarshal(v, c)
17 | }
18 |
--------------------------------------------------------------------------------
/src/utils/service_account.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | const (
4 | appspotServiceAccountSuffix = "@appspot.gserviceaccount.com"
5 | iamGServiceAccountSuffix = ".iam.gserviceaccount.com"
6 | developerGserviceAccountSuffix = "@developer.gserviceaccount.com"
7 | )
8 |
--------------------------------------------------------------------------------
/src/utils/service_account_test.go:
--------------------------------------------------------------------------------
1 | package utils_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/google/go-cmp/cmp"
7 |
8 | "github.com/nianticlabs/modron/src/utils"
9 | )
10 |
11 | func TestGetProjectFromSAEmail(t *testing.T) {
12 | tc := []struct {
13 | saEmail string
14 | expected string
15 | }{
16 | {
17 | "gitlab-sa@example-project.iam.gserviceaccount.com",
18 | "example-project",
19 | },
20 | {
21 | "example-project@appspot.gserviceaccount.com",
22 | "example-project",
23 | },
24 | {
25 | "123456789012-compute@developer.gserviceaccount.com",
26 | "",
27 | },
28 | }
29 |
30 | for _, tt := range tc {
31 | if diff := cmp.Diff(tt.expected, utils.GetGCPProjectFromSAEmail(tt.saEmail)); diff != "" {
32 | t.Errorf("unexpected result (-want +got):\n%s", diff)
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/terraform/README.md:
--------------------------------------------------------------------------------
1 | # Terraform config for Modron
2 |
3 | Apply the terraform config for Modron:
4 |
5 | ```
6 | cd dev # cd prod if you want to deploy to prod
7 | tf plan -out tf.plan
8 | # Verify in the output that all the changes make sense and align with what you want to do.
9 | tf apply "tf.plan"
10 | ```
11 |
--------------------------------------------------------------------------------
/terraform/dev/main.tf.example:
--------------------------------------------------------------------------------
1 | // place your variables here
2 |
3 | module "modron" {
4 | source = "../modron"
5 |
6 | domain = "modron-dev.example.com"
7 | env = "dev"
8 | org_id = "GCP_ORGID"
9 | project = "my-modron-dev"
10 | zone = "GCP_ZONE"
11 |
12 | modron_admins = [
13 | "group:modron-admins@example.com"
14 | ]
15 | modron_users = [
16 | "group:modron-users@example.com",
17 | ]
18 | project_admins = [
19 | "group:modron-project-admins@example.com"
20 | ]
21 | docker_registry = "mirror.gcr.io"
22 | notification_system = "https://notification-system.example.com"
23 | notification_system_client_id = "client-id"
24 | org_suffix = "@example.com"
25 | }
26 |
--------------------------------------------------------------------------------
/terraform/modron/artifact_registry.tf:
--------------------------------------------------------------------------------
1 | resource "google_artifact_registry_repository" "registry" {
2 | location = local.region
3 | repository_id = "modron"
4 | description = "Modron Docker images"
5 | format = "DOCKER"
6 | }
7 |
8 | # writer is not enough: GitLab needs to be able to delete tags
9 | # otherwise the pipeline will fail with IAM_PERMISSION_DENIED when trying to replace the :dev / :prod tags
10 | data "google_iam_policy" "modron_repository_editor_policy" {
11 | binding {
12 | role = "organizations/0123456789/roles/ArtifactRegistryDockerEditor"
13 | members = [
14 | "serviceAccount:${google_service_account.deployer_SA.email}"
15 | ]
16 | }
17 | }
18 |
19 | resource "google_artifact_registry_repository_iam_policy" "modron_repository_write_policy" {
20 | location = google_artifact_registry_repository.registry.location
21 | repository = google_artifact_registry_repository.registry.name
22 | policy_data = data.google_iam_policy.modron_repository_editor_policy.policy_data
23 | }
24 |
--------------------------------------------------------------------------------
/terraform/modron/dns_logging.tf:
--------------------------------------------------------------------------------
1 | resource "google_project_service" "dns_service" {
2 | service = "dns.googleapis.com"
3 | }
4 |
5 | resource "google_dns_policy" "dns-logging" {
6 | name = "dns-logging"
7 |
8 | enable_logging = true
9 |
10 | networks {
11 | network_url = google_compute_network.cloud_run_network.id
12 | }
13 | depends_on = [
14 | google_project_service.dns_service
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/terraform/modron/gitlab.tf:
--------------------------------------------------------------------------------
1 | resource "google_service_account" "deployer_SA" {
2 | account_id = "gitlab-deployer"
3 | description = "Used by Gitlab to deploy on Cloud Run."
4 | display_name = "gitlab-deployer"
5 | }
6 |
7 | data "google_iam_policy" "gitlab_deployer" {
8 | binding {
9 | role = "roles/iam.serviceAccountTokenCreator"
10 | members = [
11 | "serviceAccount:${var.gitlab_impersonator_service_account}",
12 | ]
13 | }
14 | count = var.gitlab_impersonator_service_account != "" ? 1 : 0
15 | }
16 |
17 | resource "google_service_account_iam_policy" "gitlab_deployer_iam_policy" {
18 | policy_data = data.google_iam_policy.gitlab_deployer[0].policy_data
19 | service_account_id = google_service_account.deployer_SA.name
20 | count = var.gitlab_impersonator_service_account != "" ? 1 : 0
21 | }
22 |
23 | resource "google_project_iam_member" "gitlab_cloud_build" {
24 | project = var.project
25 | role = "roles/cloudbuild.builds.editor"
26 | member = "serviceAccount:${google_service_account.deployer_SA.email}"
27 | }
28 |
29 | resource "google_project_iam_member" "gitlab_run_developer" {
30 | project = var.project
31 | role = "roles/run.developer"
32 | member = "serviceAccount:${google_service_account.deployer_SA.email}"
33 | }
34 |
35 | # This is required to build, according to Google it is compatible with the concept of least privilege -_-
36 | # https://cloud.google.com/build/docs/securing-builds/store-manage-build-logs#viewing_build_logs
37 | # TODO: Update to a custom log bucket and remove this permission.
38 | resource "google_project_iam_member" "gitlab_cloud_build_storage" {
39 | project = var.project
40 | role = "roles/viewer"
41 | member = "serviceAccount:${google_service_account.deployer_SA.email}"
42 | }
43 |
--------------------------------------------------------------------------------
/terraform/modron/iap.tf:
--------------------------------------------------------------------------------
1 | resource "google_iap_brand" "project_brand" {
2 | support_email = "modron-support${var.org_suffix}"
3 | application_title = "Modron ${title(var.env)}"
4 | }
5 |
6 | resource "google_iap_client" "project_client" {
7 | display_name = "Modron ${title(var.env)}"
8 | brand = google_iap_brand.project_brand.name
9 | }
10 |
11 | data "google_iam_policy" "iap_web_users" {
12 | binding {
13 | role = "roles/iap.httpsResourceAccessor"
14 | members = concat(var.modron_users, var.modron_admins)
15 | }
16 | }
17 |
18 | resource "google_iap_web_iam_policy" "users" {
19 | policy_data = data.google_iam_policy.iap_web_users.policy_data
20 | }
21 |
22 | resource "google_project_iam_member" "jump_host_ssh_accessors" {
23 | project = var.project
24 | role = "roles/iap.tunnelResourceAccessor"
25 | for_each = toset(var.modron_admins)
26 | member = each.key
27 | }
28 |
--------------------------------------------------------------------------------
/terraform/modron/main.tf:
--------------------------------------------------------------------------------
1 | provider "google" {
2 | project = var.project
3 | region = local.region
4 | access_token = data.google_service_account_access_token.sa.access_token
5 | zone = var.zone
6 | }
7 |
8 | locals {
9 | region = substr(var.zone, 0, length(var.zone) - 2)
10 | }
11 |
12 | resource "google_compute_ssl_policy" "modern_TLS_policy" {
13 | min_tls_version = "TLS_1_2"
14 | name = "modern-ssl-policy"
15 | profile = "MODERN"
16 | }
17 |
--------------------------------------------------------------------------------
/terraform/modron/network.tf:
--------------------------------------------------------------------------------
1 | resource "google_compute_network" "cloud_run_network" {
2 | auto_create_subnetworks = true
3 | mtu = 1460
4 | name = "cloud-run-network"
5 | routing_mode = "REGIONAL"
6 | depends_on = [
7 | google_project_service.compute_service
8 | ]
9 | }
10 |
11 | resource "google_compute_region_network_endpoint_group" "grpc_web_neg" {
12 | name = "modron-grpc-web-${var.env}-endpoint"
13 | network_endpoint_type = "SERVERLESS"
14 | region = local.region
15 | cloud_run {
16 | service = google_cloud_run_v2_service.grpc_web.name
17 | }
18 | }
19 |
20 | resource "google_compute_region_network_endpoint_group" "ui_neg" {
21 | name = "modron-ui-${var.env}-endpoint"
22 | network_endpoint_type = "SERVERLESS"
23 | region = local.region
24 | cloud_run {
25 | service = google_cloud_run_v2_service.ui.name
26 | }
27 | }
28 |
29 |
30 | resource "google_vpc_access_connector" "connector" {
31 | name = "cloud-run-vpc-connector"
32 | network = google_compute_network.cloud_run_network.name
33 | ip_cidr_range = "10.42.0.0/28"
34 | }
35 |
36 | # This is required to install packages on the SQL jump host
37 | resource "google_compute_router" "router" {
38 | name = "sql-jump-host"
39 | region = local.region
40 | network = google_compute_network.cloud_run_network.id
41 |
42 | bgp {
43 | asn = 64514
44 | }
45 | }
46 |
47 | resource "google_compute_router_nat" "nat" {
48 | name = "sql-jump-host"
49 | router = google_compute_router.router.name
50 | region = google_compute_router.router.region
51 | nat_ip_allocate_option = "AUTO_ONLY"
52 | source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
53 |
54 | log_config {
55 | enable = true
56 | filter = "ERRORS_ONLY"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/terraform/modron/otel/README.md:
--------------------------------------------------------------------------------
1 | # otel-collector
2 |
3 | Configuration adapted from [GoogleCloudRun/opentelemetry-cloud-run](https://github.com/GoogleCloudPlatform/opentelemetry-cloud-run)
4 |
--------------------------------------------------------------------------------
/terraform/modron/otel/config.yaml:
--------------------------------------------------------------------------------
1 | receivers:
2 | otlp:
3 | protocols:
4 | grpc:
5 | endpoint: 0.0.0.0:4317
6 | http:
7 | endpoint: 0.0.0.0:4318
8 |
9 | processors:
10 | batch:
11 | # batch metrics before sending to reduce API usage
12 | send_batch_max_size: 200
13 | send_batch_size: 200
14 | timeout: 5s
15 |
16 | memory_limiter:
17 | # drop metrics if memory usage gets too high
18 | check_interval: 1s
19 | limit_percentage: 65
20 | spike_limit_percentage: 20
21 |
22 | resourcedetection:
23 | detectors: [env, gcp]
24 | timeout: 2s
25 | override: false
26 |
27 | exporters:
28 | googlecloud:
29 | log:
30 | default_log_name: "otel-collector"
31 | otlphttp:
32 | endpoint: "http://10.43.0.2:80"
33 | tls:
34 | insecure: true
35 | googlemanagedprometheus:
36 |
37 | extensions:
38 | health_check:
39 | endpoint: "0.0.0.0:13133"
40 |
41 | service:
42 | extensions: [health_check]
43 | pipelines:
44 | traces:
45 | receivers: [otlp]
46 | processors: [resourcedetection]
47 | exporters: [googlecloud,otlphttp]
48 | logs:
49 | receivers: [otlp]
50 | processors: [resourcedetection]
51 | exporters: [googlecloud]
52 | metrics:
53 | receivers: [otlp]
54 | processors: [resourcedetection]
55 | exporters: [googlemanagedprometheus]
56 |
--------------------------------------------------------------------------------
/terraform/modron/project.tf:
--------------------------------------------------------------------------------
1 | resource "google_project_iam_binding" "cloud_sql_admins" {
2 | project = var.project
3 | role = "roles/cloudsql.admin"
4 | members = var.project_admins
5 | }
6 |
7 | resource "google_project_iam_binding" "cloud_trace_agent" {
8 | project = var.project
9 | role = "roles/cloudtrace.agent"
10 | members = [
11 | "serviceAccount:${google_service_account.modron_runner.email}",
12 | ]
13 | }
14 |
15 | resource "google_project_iam_binding" "monitoring_writer" {
16 | project = var.project
17 | role = "roles/monitoring.metricWriter"
18 | members = [
19 | "serviceAccount:${google_service_account.modron_runner.email}",
20 | ]
21 | }
--------------------------------------------------------------------------------
/terraform/modron/secret.tf:
--------------------------------------------------------------------------------
1 | resource "google_secret_manager_secret" "sql_connect_string_config" {
2 | secret_id = "sql_connect_string"
3 |
4 | replication {
5 | user_managed {
6 | replicas {
7 | location = local.region
8 | }
9 | }
10 | }
11 | }
12 |
13 | resource "google_secret_manager_secret_version" "sql_connect_string" {
14 | secret = google_secret_manager_secret.sql_connect_string_config.id
15 |
16 | # We use SQL proxy in cloud run, this is fine to disable SSL here as the proxy runs on the same instance and authenticates the instance.
17 | secret_data = "host=/cloudsql/${google_sql_database_instance.instance.connection_name} user=${google_sql_user.iam_user.name} dbname=modron${var.env} sslmode=disable password=${random_password.sql_user_password.result}"
18 | }
19 |
20 | data "google_iam_policy" "secret_accessors_policy" {
21 | binding {
22 | role = "roles/secretmanager.secretAccessor"
23 | members = [
24 | "serviceAccount:${google_service_account.modron_runner.email}"
25 | ]
26 | }
27 | }
28 |
29 | resource "google_secret_manager_secret_iam_policy" "prod_accesses_secret" {
30 | secret_id = google_secret_manager_secret.sql_connect_string_config.secret_id
31 | policy_data = data.google_iam_policy.secret_accessors_policy.policy_data
32 | }
33 |
--------------------------------------------------------------------------------
/terraform/modron/service_account.tf:
--------------------------------------------------------------------------------
1 | resource "google_service_account" "modron_runner" {
2 | account_id = "modron-${var.env}-runner"
3 | description = "Modron ${var.env} runner"
4 | display_name = "modron-${var.env}-runner"
5 | }
6 |
7 | locals {
8 | service_account_sa_users = [for v in compact([
9 | google_service_account.deployer_SA.email,
10 | var.gitlab_impersonator_service_account,
11 | ]) : "serviceAccount:${v}"]
12 | }
13 |
14 | resource "google_service_account_iam_binding" "modron_runner_user" {
15 | service_account_id = google_service_account.modron_runner.name
16 | role = "roles/iam.serviceAccountUser"
17 | members = concat(local.service_account_sa_users, var.project_admins)
18 | }
19 |
20 | resource "google_project_iam_member" "runner_log_writer" {
21 | project = var.project
22 | role = "roles/logging.logWriter"
23 | member = "serviceAccount:${google_service_account.modron_runner.email}"
24 | }
25 |
26 | resource "google_project_iam_member" "project_monitoring" {
27 | project = var.project
28 | role = "roles/monitoring.metricWriter"
29 | member = "serviceAccount:${google_service_account.modron_runner.email}"
30 | }
31 |
32 | resource "google_project_iam_member" "sql_client_iam" {
33 | project = var.project
34 | role = "roles/cloudsql.client"
35 | member = "serviceAccount:${google_service_account.modron_runner.email}"
36 | }
37 | ############
38 |
39 | resource "google_service_account" "jump_host_runner" {
40 | account_id = "modron-${var.env}-sql-jumphost"
41 | display_name = "modron-${var.env}-sql-jumphost"
42 | }
43 |
44 | resource "google_service_account_iam_binding" "jump_host_runner_user" {
45 | service_account_id = google_service_account.jump_host_runner.name
46 | role = "roles/iam.serviceAccountUser"
47 | members = var.project_admins
48 | }
49 |
50 | resource "google_project_iam_member" "jump_host_log_writer" {
51 | project = var.project
52 | role = "roles/logging.logWriter"
53 | member = "serviceAccount:${google_service_account.jump_host_runner.email}"
54 | }
55 |
--------------------------------------------------------------------------------
/terraform/modron/services.tf:
--------------------------------------------------------------------------------
1 | resource "google_project_service" "apikeys_service" {
2 | service = "apikeys.googleapis.com"
3 | }
4 | resource "google_project_service" "bigquery_service" {
5 | service = "bigquery.googleapis.com"
6 | }
7 | resource "google_project_service" "cloudasset_service" {
8 | service = "cloudasset.googleapis.com"
9 | }
10 | resource "google_project_service" "cloudidentity_service" {
11 | service = "cloudidentity.googleapis.com"
12 | }
13 | resource "google_project_service" "cloud_resource_manager_service" {
14 | service = "cloudresourcemanager.googleapis.com"
15 | }
16 | resource "google_project_service" "cloudbuild_service" {
17 | service = "cloudbuild.googleapis.com"
18 | }
19 | resource "google_project_service" "compute_service" {
20 | service = "compute.googleapis.com"
21 | }
22 | resource "google_project_service" "container_service" {
23 | service = "container.googleapis.com"
24 | }
25 | resource "google_project_service" "iam_service" {
26 | service = "iam.googleapis.com"
27 | }
28 | resource "google_project_service" "iap_service" {
29 | service = "iap.googleapis.com"
30 | }
31 | resource "google_project_service" "run_service" {
32 | service = "run.googleapis.com"
33 | }
34 | resource "google_project_service" "secretmanager_service" {
35 | service = "secretmanager.googleapis.com"
36 | }
37 | resource "google_project_service" "servicenetworking_service" {
38 | service = "servicenetworking.googleapis.com"
39 | }
40 | resource "google_project_service" "serviceusage_service" {
41 | service = "serviceusage.googleapis.com"
42 | }
43 | resource "google_project_service" "stackdriver_service" {
44 | service = "stackdriver.googleapis.com"
45 | }
46 | resource "google_project_service" "spanner_service" {
47 | service = "spanner.googleapis.com"
48 | }
49 | resource "google_project_service" "vpcaccess_service" {
50 | service = "vpcaccess.googleapis.com"
51 | }
52 |
--------------------------------------------------------------------------------
/terraform/modron/tracing.tf:
--------------------------------------------------------------------------------
1 | resource "google_storage_bucket" "otel_config" {
2 | name = "${var.project}-otel-config"
3 | location = local.region
4 | uniform_bucket_level_access = true
5 | }
6 |
7 | data "google_iam_policy" "otel_config" {
8 | binding {
9 | role = "roles/storage.objectViewer"
10 | members = [
11 | "serviceAccount:${google_service_account.modron_runner.email}",
12 | ]
13 | }
14 |
15 | binding {
16 | role = "roles/storage.admin"
17 | members = concat(
18 | var.project_admins,
19 | [
20 | "serviceAccount:${data.google_service_account.terraform_sa.email}",
21 | ]
22 | )
23 | }
24 | }
25 |
26 | resource "google_storage_bucket_iam_policy" "otel_config" {
27 | bucket = google_storage_bucket.otel_config.name
28 | policy_data = data.google_iam_policy.otel_config.policy_data
29 | }
30 |
31 | resource "google_storage_bucket_object" "otel_config" {
32 | bucket = google_storage_bucket.otel_config.name
33 | name = "config.yaml"
34 | content = file("${path.module}/otel/config.yaml")
35 | }
--------------------------------------------------------------------------------
/terraform/prod/main.tf.example:
--------------------------------------------------------------------------------
1 | // place your variables here
2 |
3 | module "modron" {
4 | source = "../modron"
5 |
6 | domain = "modron-prod.example.com"
7 | env = "prod"
8 | org_id = "GCP_ORGID"
9 | project = "my-modron-prod"
10 | zone = "GCP_ZONE"
11 |
12 | modron_admins = [
13 | "group:modron-admins@example.com"
14 | ]
15 | modron_users = [
16 | "group:modron-users@example.com",
17 | ]
18 | project_admins = [
19 | "group:modron-project-admins@example.com"
20 | ]
21 | docker_registry = "mirror.gcr.io"
22 | notification_system = "https://notification-system.example.com"
23 | notification_system_client_id = "client-id"
24 | org_suffix = "@example.com"
25 | }
26 |
--------------------------------------------------------------------------------
/utils/gcp_service_agents/.gitignore:
--------------------------------------------------------------------------------
1 | *.json
--------------------------------------------------------------------------------
/utils/gcp_service_agents/README.md:
--------------------------------------------------------------------------------
1 | # gcp_service_agents
2 |
3 | GCP publishes a list of "Service Agents" on their [documentation pages](https://cloud.google.com/iam/docs/service-agents).
4 | Unfortunately this list is not in a machine readable format. This little helper scrapes that page and provides a list
5 | of project IDs (e.g: `service-PROJECT_NUMBER@gcp-sa-aiplatform-cc.iam.gserviceaccount.com` -> `gcp-sa-aiplatform-cc`)
6 | that Google provides, and thus that are considered "secure" to be used in IAM policies.
7 |
8 | ## Usage
9 |
10 | ```bash
11 | go run ./ -o out.json
12 | jq -r '.projects[] | "\"" + . + "\"" + ": {},"' out.json | clipcopy
13 | ```
14 |
15 | Then paste the content of your clipboard into the `constants/gcp_sa_projects.go` file.
--------------------------------------------------------------------------------
/utils/gcp_service_agents/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/nianticlabs/modron/utils/gcp_service_agents
2 |
3 | go 1.23.2
4 |
5 | require (
6 | github.com/alexflint/go-arg v1.5.1
7 | github.com/sirupsen/logrus v1.9.3
8 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
9 | )
10 |
11 | require (
12 | github.com/PuerkitoBio/goquery v1.9.0 // indirect
13 | github.com/alexflint/go-scalar v1.2.0 // indirect
14 | github.com/andybalholm/cascadia v1.3.2 // indirect
15 | golang.org/x/net v0.29.0 // indirect
16 | golang.org/x/sys v0.25.0 // indirect
17 | )
18 |
--------------------------------------------------------------------------------