diff --git a/.editorconfig b/.editorconfig index 84bbaf8a420..cbde126ca6c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,6 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true max_line_length = 120 -insert_final_newline = true [*.go] indent_style = tab diff --git a/.gitignore b/.gitignore index deb1c1f882a..72f6684ef20 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ awsconfig /public_gen /public/vendor/npm /tmp -vendor/phantomjs/phantomjs -vendor/phantomjs/phantomjs.exe +tools/phantomjs/phantomjs +tools/phantomjs/phantomjs.exe profile.out coverage.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index da4fe019959..6508c4ff76a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,33 +1,37 @@ -# 5.0.0 (unreleased / master branch) +# 5.0.0-beta2 (unrelased) -Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5. +# 5.0.0-beta1 (2018-02-05) -### New Features +Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=Izr0IBgoTZQ) of Grafana v5. + +### New Major Features - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611) - **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list. - **Dashboard grid**: Panels are now layed out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093). - **Templating**: Vertical repeat direction for panel repeats. - **UX**: Major update to page header and navigation - **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750) - -## New Dashboard Grid - -The new grid engine is major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backwards compatible. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. - -Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update. - -# 4.7.0 (unreleased / v4.7.x branch) +- **Persistent dashboard url's**: New url's for dashboards that allows renaming dashboards without breaking links. [#7883](https://github.com/grafana/grafana/issues/7883) ## Breaking changes -`[dashboard.json]` have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). - +* **[dashboard.json]** have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. -The pagerduty notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) +* **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) + +* **HTTP API** + - `GET /api/alerts` property dashboardUri renamed to url and is now the full url (that is including app sub url). + +## New Dashboard Grid + +The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution. + +Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update. ## New Features +* **Alerting**: Add support for internal image store [#6922](https://github.com/grafana/grafana/issues/6922), thx [@FunkyM](https://github.com/FunkyM) * **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson) * **Postgres/MySQL**: add __timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm) * **Text**: Text panel are now edited in the ace editor. [#9698](https://github.com/grafana/grafana/pull/9698), thx [@mtanda](https://github.com/mtanda) @@ -38,8 +42,11 @@ The pagerduty notifier now defaults to not auto resolve incidents. More details * **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269) * **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda) * **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal) +* **Security**: Protect against brute force (frequent) login attempts [#7616](https://github.com/grafana/grafana/issues/7616) ## Minor +* **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc) +* **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda) * **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend) * **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured. [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell) * **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda) @@ -52,15 +59,26 @@ The pagerduty notifier now defaults to not auto resolve incidents. More details * **Azure**: Adds support for Azure blob storage as external image stor [#8955](https://github.com/grafana/grafana/issues/8955), thx [@saada](https://github.com/saada) * **Telegram**: Add support for inline image uploads to telegram notifier plugin [#9967](https://github.com/grafana/grafana/pull/9967), thx [@rburchell](https://github.com/rburchell) -## Tech -* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) - - ## Fixes * **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand) * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu) * **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm) * **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222) +* **Cloudwatch**: Fix for multi-valued templated queries. [#9903](https://github.com/grafana/grafana/issues/9903) + +## Tech +* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) + +## Deprecation notes + +### HTTP API +The following operations have been deprecated and will be removed in a future release: + - `GET /api/dashboards/db/:slug` -> Use `GET /api/dashboards/uid/:uid` instead + - `DELETE /api/dashboards/db/:slug` -> Use `DELETE /api/dashboards/uid/:uid` instead + +The following properties have been deprecated and will be removed in a future release: + - `uri` property in `GET /api/search` -> Use new `url` or `uid` property instead + - `meta.slug` property in `GET /api/dashboards/uid/:uid` and `GET /api/dashboards/db/:slug` -> Use new `meta.url` or `dashboard.uid` property instead # 4.6.3 (2017-12-14) diff --git a/Gopkg.lock b/Gopkg.lock index 0b9b74b2749..8d82b29d622 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -4,8 +4,8 @@ [[projects]] name = "cloud.google.com/go" packages = ["compute/metadata"] - revision = "050b16d2314d5fc3d4c9a51e4cd5c7468e77f162" - version = "v0.17.0" + revision = "767c40d6a2e058483c25fa193e963a22da17236d" + version = "v0.18.0" [[projects]] name = "github.com/BurntSushi/toml" @@ -27,9 +27,39 @@ [[projects]] name = "github.com/aws/aws-sdk-go" - packages = ["aws","aws/awserr","aws/awsutil","aws/client","aws/client/metadata","aws/corehandlers","aws/credentials","aws/credentials/ec2rolecreds","aws/credentials/endpointcreds","aws/credentials/stscreds","aws/defaults","aws/ec2metadata","aws/endpoints","aws/request","aws/session","aws/signer/v4","internal/shareddefaults","private/protocol","private/protocol/ec2query","private/protocol/query","private/protocol/query/queryutil","private/protocol/rest","private/protocol/restxml","private/protocol/xml/xmlutil","service/cloudwatch","service/ec2","service/ec2/ec2iface","service/s3","service/sts"] - revision = "c13a879e75646fe750d21bcb05bf2cabea9791c1" - version = "v1.12.65" + packages = [ + "aws", + "aws/awserr", + "aws/awsutil", + "aws/client", + "aws/client/metadata", + "aws/corehandlers", + "aws/credentials", + "aws/credentials/ec2rolecreds", + "aws/credentials/endpointcreds", + "aws/credentials/stscreds", + "aws/defaults", + "aws/ec2metadata", + "aws/endpoints", + "aws/request", + "aws/session", + "aws/signer/v4", + "internal/shareddefaults", + "private/protocol", + "private/protocol/ec2query", + "private/protocol/query", + "private/protocol/query/queryutil", + "private/protocol/rest", + "private/protocol/restxml", + "private/protocol/xml/xmlutil", + "service/cloudwatch", + "service/ec2", + "service/ec2/ec2iface", + "service/s3", + "service/sts" + ] + revision = "decd990ddc5dcdf2f73309cbcab90d06b996ca28" + version = "v1.12.67" [[projects]] branch = "master" @@ -43,12 +73,6 @@ packages = ["quantile"] revision = "4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9" -[[projects]] - name = "github.com/bitly/go-simplejson" - packages = ["."] - revision = "aabad6e819789e569bd6aabf444c935aa9ba1e44" - version = "v0.5.0" - [[projects]] branch = "master" name = "github.com/bmizerany/assert" @@ -118,14 +142,19 @@ [[projects]] branch = "master" name = "github.com/go-macaron/session" - packages = [".","memcache","mysql","postgres","redis"] + packages = [ + ".", + "memcache", + "mysql", + "postgres", + "redis" + ] revision = "b8e286a0dba8f4999042d6b258daf51b31d08938" [[projects]] name = "github.com/go-sql-driver/mysql" packages = ["."] - revision = "a0583e0143b1624142adab07e0e97fe106d99561" - version = "v1.3" + revision = "2cc627ac8defc45d65066ae98f898166f580f9a4" [[projects]] name = "github.com/go-stack/stack" @@ -152,8 +181,14 @@ [[projects]] branch = "master" name = "github.com/golang/protobuf" - packages = ["proto","ptypes","ptypes/any","ptypes/duration","ptypes/timestamp"] - revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845" + packages = [ + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/timestamp" + ] + revision = "c65a0412e71e8b9b3bfd22925720d23c0f054237" [[projects]] branch = "master" @@ -183,7 +218,7 @@ branch = "master" name = "github.com/hashicorp/go-hclog" packages = ["."] - revision = "ca137eb4b4389c9bc6f1a6d887f056bf16c00510" + revision = "5bcb0f17e36442247290887cc914a6e507afa5c4" [[projects]] name = "github.com/hashicorp/go-plugin" @@ -221,7 +256,10 @@ [[projects]] name = "github.com/klauspost/compress" - packages = ["flate","gzip"] + packages = [ + "flate", + "gzip" + ] revision = "6c8db69c4b49dd4df1fff66996cf556176d0b9bf" version = "v1.2.1" @@ -252,8 +290,11 @@ [[projects]] branch = "master" name = "github.com/lib/pq" - packages = [".","oid"] - revision = "27ea5d92de30060e7121ddd543fe14e9a327e0cc" + packages = [ + ".", + "oid" + ] + revision = "61fe37aa2ee24fabcdbe5c4ac1d4ac566f88f345" [[projects]] name = "github.com/mattn/go-colorable" @@ -287,7 +328,11 @@ [[projects]] name = "github.com/opentracing/opentracing-go" - packages = [".","ext","log"] + packages = [ + ".", + "ext", + "log" + ] revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" version = "v1.0.2" @@ -299,7 +344,12 @@ [[projects]] name = "github.com/prometheus/client_golang" - packages = ["api","api/prometheus/v1","prometheus","prometheus/promhttp"] + packages = [ + "api", + "api/prometheus/v1", + "prometheus", + "prometheus/promhttp" + ] revision = "967789050ba94deca04a5e84cce8ad472ce313c1" version = "v0.9.0-pre1" @@ -312,14 +362,23 @@ [[projects]] branch = "master" name = "github.com/prometheus/common" - packages = ["expfmt","internal/bitbucket.org/ww/goautoneg","model"] + packages = [ + "expfmt", + "internal/bitbucket.org/ww/goautoneg", + "model" + ] revision = "89604d197083d4781071d3c65855d24ecfb0a563" [[projects]] branch = "master" name = "github.com/prometheus/procfs" - packages = [".","xfs"] - revision = "b15cd069a83443be3154b719d0cc9fe8117f09fb" + packages = [ + ".", + "internal/util", + "nfsd", + "xfs" + ] + revision = "85fadb6e89903ef7cca6f6a804474cd5ea85b6e1" [[projects]] branch = "master" @@ -335,19 +394,47 @@ [[projects]] name = "github.com/smartystreets/assertions" - packages = [".","internal/go-render/render","internal/oglematchers"] + packages = [ + ".", + "internal/go-render/render", + "internal/oglematchers" + ] revision = "0b37b35ec7434b77e77a4bb29b79677cced992ea" version = "1.8.1" [[projects]] name = "github.com/smartystreets/goconvey" - packages = ["convey","convey/gotest","convey/reporting"] + packages = [ + "convey", + "convey/gotest", + "convey/reporting" + ] revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857" version = "1.6.3" +[[projects]] + branch = "master" + name = "github.com/teris-io/shortid" + packages = ["."] + revision = "771a37caa5cf0c81f585d7b6df4dfc77e0615b5c" + [[projects]] name = "github.com/uber/jaeger-client-go" - packages = [".","config","internal/baggage","internal/baggage/remote","internal/spanlog","log","rpcmetrics","thrift-gen/agent","thrift-gen/baggage","thrift-gen/jaeger","thrift-gen/sampling","thrift-gen/zipkincore","utils"] + packages = [ + ".", + "config", + "internal/baggage", + "internal/baggage/remote", + "internal/spanlog", + "log", + "rpcmetrics", + "thrift-gen/agent", + "thrift-gen/baggage", + "thrift-gen/jaeger", + "thrift-gen/sampling", + "thrift-gen/zipkincore", + "utils" + ] revision = "3ac96c6e679cb60a74589b0d0aa7c70a906183f7" version = "v2.11.2" @@ -359,7 +446,10 @@ [[projects]] name = "github.com/yudai/gojsondiff" - packages = [".","formatter"] + packages = [ + ".", + "formatter" + ] revision = "7b1b7adf999dab73a6eb02669c3d82dbb27a3dd6" version = "1.0.0" @@ -373,18 +463,33 @@ branch = "master" name = "golang.org/x/crypto" packages = ["pbkdf2"] - revision = "13931e22f9e72ea58bb73048bc752b48c6d4d4ac" + revision = "3d37316aaa6bd9929127ac9a527abf408178ea7b" [[projects]] branch = "master" name = "golang.org/x/net" - packages = ["context","context/ctxhttp","http2","http2/hpack","idna","internal/timeseries","lex/httplex","trace"] + packages = [ + "context", + "context/ctxhttp", + "http2", + "http2/hpack", + "idna", + "internal/timeseries", + "lex/httplex", + "trace" + ] revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec" [[projects]] branch = "master" name = "golang.org/x/oauth2" - packages = [".","google","internal","jws","jwt"] + packages = [ + ".", + "google", + "internal", + "jws", + "jwt" + ] revision = "b28fcf2b08a19742b43084fb40ab78ac6c3d8067" [[projects]] @@ -397,17 +502,44 @@ branch = "master" name = "golang.org/x/sys" packages = ["unix"] - revision = "2c42eef0765b9837fbdab12011af7830f55f88f0" + revision = "af50095a40f9041b3b38960738837185c26e9419" [[projects]] branch = "master" name = "golang.org/x/text" - packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable"] + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] revision = "e19ae1496984b1c655b8044a65c0300a3c878dd3" [[projects]] name = "google.golang.org/appengine" - packages = [".","internal","internal/app_identity","internal/base","internal/datastore","internal/log","internal/modules","internal/remote_api","internal/urlfetch","urlfetch"] + packages = [ + ".", + "cloudsql", + "internal", + "internal/app_identity", + "internal/base", + "internal/datastore", + "internal/log", + "internal/modules", + "internal/remote_api", + "internal/urlfetch", + "urlfetch" + ] revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" version = "v1.0.0" @@ -419,9 +551,34 @@ [[projects]] name = "google.golang.org/grpc" - packages = [".","balancer","balancer/base","balancer/roundrobin","codes","connectivity","credentials","encoding","grpclb/grpc_lb_v1/messages","grpclog","health","health/grpc_health_v1","internal","keepalive","metadata","naming","peer","resolver","resolver/dns","resolver/passthrough","stats","status","tap","transport"] - revision = "7cea4cc846bcf00cbb27595b07da5de875ef7de9" - version = "v1.9.1" + packages = [ + ".", + "balancer", + "balancer/base", + "balancer/roundrobin", + "codes", + "connectivity", + "credentials", + "encoding", + "grpclb/grpc_lb_v1/messages", + "grpclog", + "health", + "health/grpc_health_v1", + "internal", + "keepalive", + "metadata", + "naming", + "peer", + "resolver", + "resolver/dns", + "resolver/passthrough", + "stats", + "status", + "tap", + "transport" + ] + revision = "6b51017f791ae1cfbec89c52efdf444b13b550ef" + version = "v1.9.2" [[projects]] branch = "v3" @@ -474,6 +631,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "1824c50aa4685fb22643ab89d45457238d9e6c8f7980315c2a41bb35222ea1a7" + inputs-digest = "4de68f1342ba98a637ec8ca7496aeeae2021bf9e4c7c80db7924e14709151a62" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 577bb1654a2..22c56d29c71 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,4 +1,3 @@ - # Gopkg.toml example # # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md @@ -20,6 +19,11 @@ # name = "github.com/x/y" # version = "2.4.0" +ignored = [ + "github.com/grafana/grafana/data/*", + "github.com/grafana/grafana/public/*", + "github.com/grafana/grafana/node_modules/*" + ] [[constraint]] name = "github.com/BurntSushi/toml" @@ -38,10 +42,6 @@ branch = "master" name = "github.com/benbjohnson/clock" -[[constraint]] - name = "github.com/bitly/go-simplejson" - version = "0.5.0" - [[constraint]] branch = "master" name = "github.com/bmizerany/assert" @@ -76,7 +76,8 @@ [[constraint]] name = "github.com/go-sql-driver/mysql" - version = "1.3.0" + revision = "2cc627ac8defc45d65066ae98f898166f580f9a4" + #version = "1.3.0" //keeping this since we would rather depend on version then commit [[constraint]] name = "github.com/go-stack/stack" @@ -174,7 +175,7 @@ [[constraint]] name = "gopkg.in/gomail.v2" - branch = "v2" + branch = "v2" [[constraint]] name = "gopkg.in/ini.v1" @@ -190,5 +191,9 @@ [prune] non-go = true - test-go = true + go-tests = true unused-packages = true + +[[constraint]] + branch = "master" + name = "github.com/teris-io/shortid" diff --git a/circle.yml b/circle.yml index 4eb600bfde3..bf013e3f5b1 100644 --- a/circle.yml +++ b/circle.yml @@ -9,7 +9,7 @@ machine: GOPATH: "/home/ubuntu/.go_workspace" ORG_PATH: "github.com/grafana" REPO_PATH: "${ORG_PATH}/grafana" - GODIST: "go1.9.2.linux-amd64.tar.gz" + GODIST: "go1.9.3.linux-amd64.tar.gz" post: - mkdir -p ~/download - mkdir -p ~/docker diff --git a/conf/defaults.ini b/conf/defaults.ini index 4e2929096a6..3766c829323 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -174,6 +174,9 @@ disable_gravatar = false # data source proxy whitelist (ip_or_domain:port separated by spaces) data_source_proxy_whitelist = +# disable protection against brute force login attempts +disable_brute_force_login_protection = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options @@ -473,7 +476,7 @@ sampler_param = 1 #################################### External Image Storage ############## [external_image_storage] -# You can choose between (s3, webdav, gcs, azure_blob) +# You can choose between (s3, webdav, gcs, azure_blob, local) provider = [external_image_storage.s3] @@ -499,3 +502,6 @@ path = account_name = account_key = container_name = + +[external_image_storage.local] +# does not require any configuration diff --git a/conf/sample.ini b/conf/sample.ini index d297d2db66a..784f6b7cfc9 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -162,6 +162,9 @@ log_queries = # data source proxy whitelist (ip_or_domain:port separated by spaces) ;data_source_proxy_whitelist = +# disable protection against brute force login attempts +;disable_brute_force_login_protection = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options @@ -417,7 +420,7 @@ log_queries = #################################### External image storage ########################## [external_image_storage] # Used for uploading images to public servers so they can be included in slack/email messages. -# you can choose between (s3, webdav, gcs, azure_blob) +# you can choose between (s3, webdav, gcs, azure_blob, local) ;provider = [external_image_storage.s3] @@ -442,3 +445,6 @@ log_queries = ;account_name = ;account_key = ;container_name = + +[external_image_storage.local] +# does not require any configuration diff --git a/docker/blocks/mysql/docker-compose.yaml b/docker/blocks/mysql/docker-compose.yaml index 6eee158ac43..f7881e66539 100644 --- a/docker/blocks/mysql/docker-compose.yaml +++ b/docker/blocks/mysql/docker-compose.yaml @@ -12,3 +12,10 @@ - /etc/timezone:/etc/timezone:ro command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all] + fake-mysql-data: + image: grafana/fake-data-gen + network_mode: bridge + environment: + FD_DATASOURCE: mysql + FD_PORT: 3306 + diff --git a/docker/blocks/mysql_tests/docker-compose.yaml b/docker/blocks/mysql_tests/docker-compose.yaml index c6c3097d463..3c59b66b5ac 100644 --- a/docker/blocks/mysql_tests/docker-compose.yaml +++ b/docker/blocks/mysql_tests/docker-compose.yaml @@ -7,4 +7,7 @@ MYSQL_PASSWORD: password ports: - "3306:3306" + volumes: + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro tmpfs: /var/lib/mysql:rw diff --git a/docker/blocks/postgres/docker-compose.yaml b/docker/blocks/postgres/docker-compose.yaml index eced00aafeb..566df7b8877 100644 --- a/docker/blocks/postgres/docker-compose.yaml +++ b/docker/blocks/postgres/docker-compose.yaml @@ -7,3 +7,10 @@ ports: - "5432:5432" command: postgres -c log_connections=on -c logging_collector=on -c log_destination=stderr -c log_directory=/var/log/postgresql + + fake-postgres-data: + image: grafana/fake-data-gen + network_mode: bridge + environment: + FD_DATASOURCE: postgres + FD_PORT: 5432 \ No newline at end of file diff --git a/docker/blocks/prometheus/Dockerfile b/docker/blocks/prometheus/Dockerfile index 1ad28f524ff..2098e6527d3 100644 --- a/docker/blocks/prometheus/Dockerfile +++ b/docker/blocks/prometheus/Dockerfile @@ -1,3 +1,3 @@ -FROM prom/prometheus +FROM prom/prometheus:v1.8.2 ADD prometheus.yml /etc/prometheus/ ADD alert.rules /etc/prometheus/ diff --git a/docs/Dockerfile b/docs/Dockerfile index 7679f2b7e4b..faf90ea0ecd 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -9,5 +9,6 @@ FROM grafana/docs-base:latest COPY config.toml /site COPY awsconfig /site +COPY versions.json /site/static/js VOLUME ["/site/content"] diff --git a/docs/sources/administration/permissions.md b/docs/sources/administration/permissions.md new file mode 100644 index 00000000000..5796d50f1fe --- /dev/null +++ b/docs/sources/administration/permissions.md @@ -0,0 +1,85 @@ ++++ +title = "Permissions" +description = "Grafana user permissions" +keywords = ["grafana", "configuration", "documentation", "admin", "users", "permissions"] +type = "docs" +aliases = ["/reference/admin"] +[menu.docs] +name = "Permissions" +parent = "admin" +weight = 3 ++++ + +# Permissions + +Grafana users have permissions that are determined by their: + +- **Organization Role** (Admin, Editor, Viewer) +- Via **Team** memberships where the **Team** has been assigned specific permissions. +- Via permissions assigned directly to user (on folders or dashboards) +- The Grafana Admin (i.e. Super Admin) user flag. + +## Organization Roles + +Users can be belong to one or more organizations. A user's organization membership is tied to a role that defines what the user is allowed to do +in that organization. + +### Admin Role + +Can do everything scoped to the organization. For example: + +- Add & Edit data sources. +- Add & Edit organization users & teams. +- Configure App plugins & set org settings. + +### Editor Role + +- Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards. +- **Cannot** create or edit data sources nor invite new users. + +### Viewer Role + +- View any dashboard. This can be disabled on specific folders and dashboards. +- **Cannot** create or edit dashboards nor data sources. + +This role can be tweaked via Grafana server setting [viewers_can_edit]({{< relref "installation/configuration.md#viewers-can-edit" >}}). If you set this to true users +with **Viewer** can also make transient dashboard edits, meaning they can modify panels & queries but not save the changes (nor create new dashboards). +Useful for public Grafana installations where you want anonymous users to be able to edit panels & queries but not save or create new dashboards. + +## Grafana Admin + +This admin flag makes a user a `Super Admin`. This means they can access the `Server Admin` views where all users and organizations can be administrated. + +### Dashboard & Folder Permissions + +> Introduced in Grafana v5.0 + +{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}} + +For dashboards and dashboard folders there is a **Permissions** page that make it possible to +remove the default role based permssions for Editors and Viewers. It's here you can add and assign permissions to specific **Users** and **Teams**. + +You can assign & remove permissions for **Organization Roles**, **Users** and **Teams**. + +Permission levels: + +- **Admin**: Can edit & create dashboards and edit permissions. +- **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions. +- **View**: Can only view existing dashboars/folders. + +#### Restricting access + +The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the +Access Control List (ACL). + +- You cannot override permissions for users with **Org Admin Role** +- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule. + +### Data source permissions + +Permissions on dashboards and folders **do not** include permissions on data sources. A user with `Viewer` role +can still issue any possible query to a data source, not just those queries that exist on dashboards he/she has access to. +We hope to add permissions on data sources in a future release. Until then **do not** view dashboard permissions as a secure +way to restrict user data access. Dashboard permissions only limits what dashboards & folders a user can view & edit not which +data sources a user can access nor what queries a user can issue. + diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 0750131d42f..c3595969281 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -3,6 +3,7 @@ title = "Provisioning" description = "" keywords = ["grafana", "provisioning"] type = "docs" +aliases = ["/installation/provisioning"] [menu.docs] parent = "admin" weight = 8 @@ -66,7 +67,6 @@ Tool | Project -----|------------ Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana) Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana) -Ansible | [https://github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana) Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana) Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana) diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 9f94810ff4d..ae68e39c26d 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -149,8 +149,10 @@ Prometheus Alertmanager | `prometheus-alertmanager` | no # Enable images in notifications {#external-image-store} -Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessible (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports -Amazon S3, Webdav, and Azure Blob Storage for this. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file. +Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessable (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports +Amazon S3, Webdav, Google Cloud Storage and Azure Blob Storage. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file. + +Be aware that some notifiers requires public access to the image to be able to include it in the notification. So make sure to enable public access to the images. If your using local image uploader, your Grafana instance need to be accessible by the internet. Currently only the Email Channels attaches images if no external image store is specified. To include images in alert notifications for other channels then you need to set up an external image store. diff --git a/docs/sources/features/index.md b/docs/sources/features/index.md index 1352e9f0576..d7fae41682b 100644 --- a/docs/sources/features/index.md +++ b/docs/sources/features/index.md @@ -5,7 +5,7 @@ type = "docs" [menu.docs] name = "Features" identifier = "features" -weight = 3 +weight = 4 +++ diff --git a/docs/sources/guides/basic_concepts.md b/docs/sources/guides/basic_concepts.md index cb64b105349..b710a227a79 100644 --- a/docs/sources/guides/basic_concepts.md +++ b/docs/sources/guides/basic_concepts.md @@ -7,6 +7,7 @@ type = "docs" name = "Basic Concepts" identifier = "basic_concepts" parent = "guides" +weight = 2 +++ # Basic Concepts diff --git a/docs/sources/guides/getting_started.md b/docs/sources/guides/getting_started.md index 5c02042481f..2f6f9a30def 100644 --- a/docs/sources/guides/getting_started.md +++ b/docs/sources/guides/getting_started.md @@ -8,6 +8,7 @@ aliases = ["/guides/gettingstarted"] name = "Getting Started" identifier = "getting_started_guide" parent = "guides" +weight = 1 +++ # Getting started @@ -24,7 +25,7 @@ Read the [Basic Concepts](/guides/basic_concepts) document to get a crash course ### Top header -Let's start with creating a new Dashboard. You can find the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard. +Let's start with creating a new Dashboard. You can find the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard. diff --git a/docs/sources/guides/index.md b/docs/sources/guides/index.md index c80dd624624..2ea09142880 100644 --- a/docs/sources/guides/index.md +++ b/docs/sources/guides/index.md @@ -4,6 +4,6 @@ type = "docs" [menu.docs] name = "Getting Started" identifier = "guides" -weight = 2 +weight = 3 +++ diff --git a/docs/sources/guides/whats-new-in-v5.md b/docs/sources/guides/whats-new-in-v5.md new file mode 100644 index 00000000000..bd960ed1694 --- /dev/null +++ b/docs/sources/guides/whats-new-in-v5.md @@ -0,0 +1,154 @@ ++++ +title = "What's New in Grafana v5.0" +description = "Feature & improvement highlights for Grafana v5.0" +keywords = ["grafana", "new", "documentation", "5.0"] +type = "docs" +[menu.docs] +name = "Version 5.0" +identifier = "v5.0" +parent = "whatsnew" +weight = -6 ++++ + +# What's New in Grafana v5.0 + +> Out in beta: [Download now!](https://grafana.com/grafana/download/5.0.0-beta1) + +This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements. + +- [New Dashboard Layout Engine]({{< relref "#new-dashboard-layout-engine" >}}) enables a much easier drag, drop and resize experience and new types of layouts. +- [New UX]({{< relref "#new-ux-layout-engine" >}}). The UI has big improvements in both look and function. +- [New Light Theme]({{< relref "#new-light-theme" >}}) is now looking really nice. +- [Dashboard Folders]({{< relref "#dashboard-folders" >}}) helps you keep your dashboards organized. +- [Permissions]({{< relref "#dashboard-folders" >}}) on folders and dashboards helps manage larger Grafana installations. +- [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system. +- [Datasource provisioning]({{< relref "#data-sources" >}}) makes it possible to setup datasources via config files. +- [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to setup dashboards via config files. +- [Persistent dashboard url's]({{< relref "#dashboard-model-persistent-url-s-and-api-changes" >}}) makes it possible to rename dashboards without breaking links. +- [Graphite Tags & Integrated Function Docs]({{< relref "#graphite-tags-integrated-function-docs" >}}). + +### Video showing new features + + +
+ +## New Dashboard Layout Engine + +{{< docs-imagebox img="/img/docs/v50/new_grid.png" max-width="1000px" class="docs-image--right">}} + +The new dashboard layout engine allows for much easier movement and sizing of panels, as other panels now move out of the way in +a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens +up many new types of layouts where panels of different heights can be aligned easily. Checkout the new grid in the video +above or on the [play site](http://play.grafana.org). All your existing dashboards will automatically migrate to the +new position system and look close to identical. The new panel position makes dashboards saved in v5.0 incompatible +with older versions of Grafana. + +
+ +## New UX + +{{< docs-imagebox img="/img/docs/v50/new_ux_nav.png" max-width="1000px" class="docs-image--right" >}} + +Almost every page has seen significant UX improvements. All pages (except dashboard pages) have a new tab-based layout that improves navigation between pages. The side menu has also changed quite a bit. You can still hide the side menu completely if you click on the Grafana logo. + +
+ +## Dashboard Settings + +{{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}} +Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard +settings views have been combined with a side nav which allows you to easily move between different setting categories. + +
+ +## New Light Theme + +{{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}} + +This theme has not seen a lot of love in recent years and we felt it was time to give it a major overhaul. We are very happy with the result. + +
+ +## Dashboard Folders + +{{< docs-imagebox img="/img/docs/v50/new_search.png" max-width="1000px" class="docs-image--right" >}} + +The big new feature that comes with Grafana v5.0 is dashboard folders. Now you can organize your dashboards in folders, +which is very useful if you have a lot of dashboards or multiple teams. + +- New search design adds expandable sections for each folder, starred and recently viewed dashboards. +- New manage dashboard pages enable batch actions and views for folder settings and permissions. +- Set permissions on folders and have dashboards inherit the permissions. + +## Teams + +A team is a new concept in Grafana v5. They are simply a group of users that can be used in the new permission system for dashboards and folders. Only an admin can create teams. +We hope to do more with teams in future releases like integration with LDAP and a team landing page. + +## Permissions + +{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="1000px" class="docs-image--right" >}} + +You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and +replaced with specific teams or users enabling more control over what a user can see and edit. + +Dashboard permissions only limits what dashboards & folders a user can view & edit not which +data sources a user can access nor what queries a user can issue. + +
+ +## Provisioning from configuration + +In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards. +But that required the service to be running before you started creating dashboards and you also needed to +set up credentials for the HTTP API. In v5.0 we decided to improve this experience by adding a new active +provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can +be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs +and alerts as well. + +### Data sources + +Data sources can now be setup using config files. These data sources are by default not editable from the Grafana GUI. +It's also possible to update and delete data sources from the config file. More info in the [data source provisioning docs](/administration/provisioning/#datasources). + +### Dashboards + +We also deprecated the `[dashboard.json]` in favor of our new dashboard provisioner that keeps dashboards on disk +in sync with dashboards in Grafana's database. The dashboard provisioner has multiple advantages over the old +`[dashboard.json]` feature. Instead of storing the dashboard in memory we now insert the dashboard into the database, +which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that +expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards) + + +## Graphite Tags & Integrated Function Docs + +{{< docs-imagebox img="/img/docs/v50/graphite_tags.png" max-width="1000px" class="docs-image--right" >}} + +The Graphite query editor has been updated to support the latest Graphite version (v1.2) that adds +many new functions and support for querying by tags. You can now also view function documentation right in the query editor! + +Read more on [Graphite Tag Support](http://graphite.readthedocs.io/en/latest/tags.html?highlight=tags). + +
+ +## Dashboard model, persistent url's and API changes + +We are introducing a new unique identifier (`uid`) in the dashboard JSON model. It's automatically +generated if not provided when creating a dashboard and will have a length of 9-12 characters. + +The unique identifier allows having persistent URL's for accessing dashboards, sharing them +between instances and when using [dashboard provisioning](#dashboards). This means that dashboard can +be renamed without breaking any links. We're changing the url format for dashboards +from `/dashboard/db/:slug` to `/d/:uid/:slug`. We'll keep supporting the old slug-based url's for dashboards +and redirects to the new one for backward compatibility. Please note that the old slug-based url's +have been deprecated and will be removed in a future release. + +Sharing dashboards between instances becomes much easier since the `uid` is unique (unique enough). +This might seem like a small change, but we are incredibly excited about it since it will make it +much easier to manage, collaborate and navigate between dashboards. + +### API changes +New uid-based routes in the dashboard API have been introduced to retrieve and delete dashboards. +The corresponding slug-based routes have been deprecated and will be removed in a future release. + + diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index 221552414e9..3860ae490b1 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -62,7 +62,7 @@ Content-Type: application/json } "newStateDate": "2016-12-25", "executionError": "", - "dashboardUri": "http://grafana.com/dashboard/db/sensors" + "url": "http://grafana.com/dashboard/db/sensors" } ] ``` @@ -94,7 +94,7 @@ Content-Type: application/json "state": "alerting", "newStateDate": "2016-12-25", "executionError": "", - "dashboardUri": "http://grafana.com/dashboard/db/sensors" + "url": "http://grafana.com/dashboard/db/sensors" } ``` @@ -196,7 +196,7 @@ Content-Type: application/json ## Create alert notification -You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. +You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. `POST /api/alert-notifications` @@ -294,4 +294,4 @@ Content-Type: application/json { "message": "Notification deleted" } -``` \ No newline at end of file +``` diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index 364b55b0cfc..468a812c709 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -90,7 +90,7 @@ Content-Type: application/json ## Get a single data source by Name -`GET /api/datasources/name/:name` +`GET /api/datasources/:name` **Example Request**: diff --git a/docs/sources/http_api/index.md b/docs/sources/http_api/index.md index 9c5d0e7d49a..cbfe004b14c 100644 --- a/docs/sources/http_api/index.md +++ b/docs/sources/http_api/index.md @@ -18,12 +18,15 @@ dashboards, creating users and updating data sources. ## Supported HTTP APIs: -* [Authentication API]({{< relref "auth.md" >}}) -* [Dashboard API]({{< relref "dashboard.md" >}}) -* [Data Source API]({{< relref "data_source.md" >}}) -* [Organisation API]({{< relref "org.md" >}}) -* [User API]({{< relref "user.md" >}}) -* [Admin API]({{< relref "admin.md" >}}) -* [Snapshot API]({{< relref "snapshot.md" >}}) -* [Preferences API]({{< relref "preferences.md" >}}) -* [Other API]({{< relref "other.md" >}}) +* [Authentication API]({{< relref "/http_api/auth.md" >}}) +* [Dashboard API]({{< relref "/http_api/dashboard.md" >}}) +* [Dashboard Versions API]({{< relref "http_api/dashboard_versions.md" >}}) +* [Data Source API]({{< relref "http_api/data_source.md" >}}) +* [Organisation API]({{< relref "http_api/org.md" >}}) +* [Snapshot API]({{< relref "http_api/snapshot.md" >}}) +* [Annotations API]({{< relref "http_api/annotations.md" >}}) +* [Alerting API]({{< relref "http_api/alerting.md" >}}) +* [User API]({{< relref "http_api/user.md" >}}) +* [Admin API]({{< relref "http_api/admin.md" >}}) +* [Preferences API]({{< relref "http_api/preferences.md" >}}) +* [Other API]({{< relref "http_api/other.md" >}}) diff --git a/docs/sources/index.md b/docs/sources/index.md index 7a431e29692..c1072db47a5 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -4,10 +4,6 @@ description = "Install guide for Grafana" keywords = ["grafana", "installation", "documentation"] type = "docs" aliases = ["v1.1", "guides/reference/admin"] -[menu.docs] -name = "Welcome to the Docs" -identifier = "root" -weight = -1 +++ # Welcome to the Grafana Documentation @@ -22,7 +18,7 @@ other domains including industrial sensors, home automation, weather, and proces - [Installing on Mac OS X](installation/mac) - [Installing on Windows](installation/windows) - [Installing on Docker](installation/docker) -- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](installation/provisioning) +- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](administration/provisioning#configuration-management-tools) - [Nightly Builds](https://grafana.com/grafana/download) For other platforms Read the [build from source]({{< relref "project/building_from_source.md" >}}) diff --git a/docs/sources/installation/behind_proxy.md b/docs/sources/installation/behind_proxy.md index f151a66662e..f1a00a5b1cc 100644 --- a/docs/sources/installation/behind_proxy.md +++ b/docs/sources/installation/behind_proxy.md @@ -68,6 +68,23 @@ server { } } ``` + +#### HAProxy configuration with sub path +```bash +frontend http-in + bind *:80 + use_backend grafana_backend if { path /grafana } or { path_beg /grafana/ } + +backend grafana_backend + # Requires haproxy >= 1.6 + http-request set-path %[path,regsub(^/grafana/?,/)] + + # Works for haproxy < 1.6 + # reqrep ^([^\ ]*\ /)grafana[/]?(.*) \1\2 + + server grafana localhost:3000 +``` + ### IIS URL Rewrite Rule (Windows) with Subpath IIS requires that the URL Rewrite module is installed. diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 01a26f38b9a..498ab7fc8df 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -540,6 +540,70 @@ allowed_organizations = allowed_organizations = ``` +### Set up oauth2 with Auth0 + +1. Create a new Client in Auth0 + - Name: Grafana + - Type: Regular Web Application + +2. Go to the Settings tab and set: + - Allowed Callback URLs: `https:///login/generic_oauth` + +3. Click Save Changes, then use the values at the top of the page to configure Grafana: + + ```bash + [auth.generic_oauth] + enabled = true + allow_sign_up = true + team_ids = + allowed_organizations = + name = Auth0 + client_id = + client_secret = + scopes = openid profile email + auth_url = https:///authorize + token_url = https:///oauth/token + api_url = https:///userinfo + ``` + +### Set up oauth2 with Azure Active Directory + +1. Log in to portal.azure.com and click "Azure Active Directory" in the side menu, then click the "Properties" sub-menu item. + +2. Copy the "Directory ID", this is needed for setting URLs later + +3. Click "App Registrations" and add a new application registration: + - Name: Grafana + - Application type: Web app / API + - Sign-on URL: `https:///login/generic_oauth` + +4. Click the name of the new application to open the application details page. + +5. Note down the "Application ID", this will be the OAuth client id. + +6. Click "Settings", then click "Keys" and add a new entry under Passwords + - Key Description: Grafana OAuth + - Duration: Never Expires + +7. Click Save then copy the key value, this will be the OAuth client secret. + +8. Configure Grafana as follows: + + ```bash + [auth.generic_oauth] + name = Azure AD + enabled = true + allow_sign_up = true + client_id = + client_secret = + scopes = openid email name + auth_url = https://login.microsoftonline.com//oauth2/authorize + token_url = https://login.microsoftonline.com//oauth2/token + api_url = + team_ids = + allowed_organizations = + ``` +
## [auth.basic] @@ -607,31 +671,6 @@ session provider you have configured. - **memcache:** ex: 127.0.0.1:11211 - **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana` -If you use MySQL or Postgres as the session store you need to create the -session table manually. - -Mysql Example: - -```bash -CREATE TABLE `session` ( - `key` CHAR(16) NOT NULL, - `data` BLOB, - `expiry` INT(11) UNSIGNED NOT NULL, - PRIMARY KEY (`key`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8; -``` - -Postgres Example: - -```bash -CREATE TABLE session ( - key CHAR(16) NOT NULL, - data BYTEA, - expiry INTEGER NOT NULL, - PRIMARY KEY (key) -); -``` - Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default). ### cookie_name @@ -766,7 +805,7 @@ Time to live for snapshots. These options control how images should be made public so they can be shared on services like slack. ### provider -You can choose between (s3, webdav, gcs, azure_blob). If left empty Grafana will ignore the upload action. +You can choose between (s3, webdav, gcs, azure_blob, local). If left empty Grafana will ignore the upload action. ## [external_image_storage.s3] diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index b742e96c869..bfc7fdc0a3d 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -16,6 +16,7 @@ weight = 1 Description | Download ------------ | ------------- Stable for Debian-based Linux | [grafana_4.6.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb) +Beta for Debian-based Linux | [grafana_5.0.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -27,6 +28,15 @@ installation. wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb sudo apt-get install -y adduser libfontconfig sudo dpkg -i grafana_4.6.3_amd64.deb +``` + +## Install Latest Beta + +```bash +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb +sudo apt-get install -y adduser libfontconfig +sudo dpkg -i grafana_5.0.0-beta1_amd64.deb + ``` ## APT Repository diff --git a/docs/sources/installation/index.md b/docs/sources/installation/index.md index 47d52817b64..a86f426dc13 100644 --- a/docs/sources/installation/index.md +++ b/docs/sources/installation/index.md @@ -7,6 +7,7 @@ aliases = ["installation/installation/", "v2.1/installation/install/"] [menu.docs] name = "Installation" identifier = "installation" +weight = 1 +++ ## Installing Grafana diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index d3e796a78c8..f0c498c819f 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -16,6 +16,7 @@ weight = 2 Description | Download ------------ | ------------- Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm) +Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. @@ -28,6 +29,12 @@ You can install Grafana using Yum directly. $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm ``` +## Install Beta + +```bash +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm +``` + Or install manually using `rpm`. #### On CentOS / Fedora / Redhat: diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index 6a4b4e8f047..af40c20a40b 100644 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -101,3 +101,8 @@ as this will make upgrades easier without risking losing your config changes. ## Upgrading from 2.x We are not aware of any issues upgrading directly from 2.x to 4.x but to be on the safe side go via 3.x => 4.x. + +## Upgrading to v5.0 + +The dashboard grid layout engine has changed. All dashboards will be automatically upgraded to new +positioning system when you load them in v5. Dashboards saved in v5 will not work in older versions of Grafana. diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 7c6a97085df..08d234d63f9 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -14,6 +14,7 @@ weight = 3 Description | Download ------------ | ------------- Latest stable package for Windows | [grafana.4.6.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3.windows-x64.zip) +Latest beta package for Windows | [grafana.5.0.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.windows-x64.zip) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. diff --git a/docs/sources/plugins/developing/datasources.md b/docs/sources/plugins/developing/datasources.md index 0149f06e1aa..09a005ba714 100644 --- a/docs/sources/plugins/developing/datasources.md +++ b/docs/sources/plugins/developing/datasources.md @@ -84,15 +84,15 @@ An array of: { "target":"upper_75", "datapoints":[ - [622,1450754160000], - [365,1450754220000] + [622, 1450754160000], + [365, 1450754220000] ] }, { "target":"upper_90", "datapoints":[ - [861,1450754160000], - [767,1450754220000] + [861, 1450754160000], + [767, 1450754220000] ] } ] diff --git a/docs/sources/reference/admin.md b/docs/sources/reference/admin.md deleted file mode 100644 index a6863b4ea71..00000000000 --- a/docs/sources/reference/admin.md +++ /dev/null @@ -1,42 +0,0 @@ -+++ -title = "Admin Roles" -description = "Users & Organization permission and administration" -keywords = ["grafana", "configuration", "documentation", "admin", "users", "permissions"] -type = "docs" -[menu.docs] -name = "Admin Roles" -parent = "admin" -weight = 3 -+++ - -# Administration - -Grafana has two levels of administrators: - -* Organizational administrators: These admins can manage users within specific organizations in a particular Grafana installation -* Grafana administrators: These super admins can manage users across all organizations in a Grafana installation. They can also change and access system-wide settings. - -## Organizational Administrators - -As an Organizational administrator, you can add `Data Sources`, add Users to your Organization and -modify Organization details and options. - -> *Note*: If Grafana is configured with `users.allow_org_create = true`, any User of any Organization will be able to -> start their own Organization and become the administrator of that Organization. - - -## Grafana Administrators - - -As a Grafana Administrator, you have complete access to any Organization or User in that instance of Grafana. -When performing actions as a Grafana admin, the sidebar will change it's appearance as below to indicate you are performing global server administration. - -From the Grafana Server Admin page, you can access the System Info page which summarizes all of the backend configuration settings of the Grafana server. - -## Why would I have multiple Organizations? - -Organizations in Grafana are best suited for a **multi-tenant deployment**. In a multi-tenant deployment, -Organizations can be used to provide a full Grafana experience to different sets of users from a single Grafana instance, -at the convenience of the Grafana Administrator. - -In most cases, a Grafana installation will only have **one** Organization. Since dashboards, data sources and other configuration items are not shared between organizations, there's no need to create multiple Organizations if you want all your users to have access to the same set of dashboards and data. diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index badd3b5712a..20aea1acd2e 100644 --- a/docs/sources/reference/sharing.md +++ b/docs/sources/reference/sharing.md @@ -39,7 +39,7 @@ Click a panel title to open the panel menu, then click share in the panel menu t ### Direct Link Rendered Image -You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `vendor/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig) are available. +You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `tools/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig) are available. Example of a link to a server-side rendered PNG: diff --git a/docs/sources/features/whatsnew/index.md b/docs/sources/whatsnew/index.md similarity index 90% rename from docs/sources/features/whatsnew/index.md rename to docs/sources/whatsnew/index.md index 30357af4668..df472f07093 100644 --- a/docs/sources/features/whatsnew/index.md +++ b/docs/sources/whatsnew/index.md @@ -3,7 +3,7 @@ title = "What's New in Grafana" [menu.docs] name = "What's New In Grafana" identifier = "whatsnew" -weight = 2 +weight = 3 +++ diff --git a/docs/versions.json b/docs/versions.json new file mode 100644 index 00000000000..03cb40f0e1f --- /dev/null +++ b/docs/versions.json @@ -0,0 +1,9 @@ +[ + { "version": "v5.0", "path": "/v5.0", "archived": false }, + { "version": "v4.6", "path": "/", "archived": false, "current": true }, + { "version": "v4.5", "path": "/v4.5", "archived": true }, + { "version": "v4.4", "path": "/v4.4", "archived": true }, + { "version": "v4.3", "path": "/v4.3", "archived": true }, + { "version": "v4.1", "path": "/v4.1", "archived": true }, + { "version": "v3.1", "path": "/v3.1", "archived": true } +] diff --git a/package.json b/package.json index 971e63053fc..aad7d2ce92c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "5.0.0-pre1", + "version": "5.0.0-beta1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -68,6 +68,7 @@ "karma-webpack": "^2.0.4", "lint-staged": "^6.0.0", "load-grunt-tasks": "3.5.2", + "mobx-react-devtools": "^4.2.15", "mocha": "^4.0.1", "ng-annotate-loader": "^0.6.1", "ng-annotate-webpack-plugin": "^0.2.1-pre", @@ -91,7 +92,7 @@ "typescript": "^2.6.2", "webpack": "^3.10.0", "webpack-bundle-analyzer": "^2.9.0", - "webpack-cleanup-plugin": "^0.5.1", + "webpack-cleanup-plugin": "^0.5.1", "webpack-merge": "^4.1.0", "zone.js": "^0.7.2" }, @@ -114,6 +115,10 @@ "*.scss": [ "prettier --write", "git add" + ], + "*.go": [ + "gofmt -w -s", + "git add" ] }, "prettier": { @@ -134,7 +139,7 @@ "clipboard": "^1.7.1", "d3": "^4.11.0", "d3-scale-chromatic": "^1.1.1", - "eventemitter3": "^2.0.2", + "eventemitter3": "^2.0.3", "file-saver": "^1.3.3", "jquery": "^3.2.1", "lodash": "^4.17.4", @@ -148,11 +153,13 @@ "react": "^16.2.0", "react-dom": "^16.2.0", "react-grid-layout": "^0.16.2", - "react-popper": "^0.7.5", "react-highlight-words": "^0.10.0", + "react-popper": "^0.7.5", "react-select": "^1.1.0", "react-sizeme": "^2.3.6", + "react-transition-group": "^2.2.1", "remarkable": "^1.7.1", + "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "^5.4.3", "tether": "^1.4.0", "tether-drop": "https://github.com/torkelo/drop", diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index 276193ad63f..ca5e7aea90c 100755 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -deb_ver=4.6.0-beta1 -rpm_ver=4.6.0-beta1 +deb_ver=5.0.0-beta1 +rpm_ver=5.0.0-beta1 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index d11122016c4..16f5f7ceb6f 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/services/guardian" ) func ValidateOrgAlert(c *middleware.Context) { @@ -62,9 +63,22 @@ func GetAlerts(c *middleware.Context) Response { return ApiError(500, "List alerts failed", err) } + alertDTOs, resp := transformToDTOs(query.Result, c) + if resp != nil { + return resp + } + + return Json(200, alertDTOs) +} + +func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.AlertRule, Response) { + if len(alerts) == 0 { + return []*dtos.AlertRule{}, nil + } + dashboardIds := make([]int64, 0) alertDTOs := make([]*dtos.AlertRule, 0) - for _, alert := range query.Result { + for _, alert := range alerts { dashboardIds = append(dashboardIds, alert.DashboardId) alertDTOs = append(alertDTOs, &dtos.AlertRule{ Id: alert.Id, @@ -83,22 +97,40 @@ func GetAlerts(c *middleware.Context) Response { DashboardIds: dashboardIds, } - if len(alertDTOs) > 0 { - if err := bus.Dispatch(&dashboardsQuery); err != nil { - return ApiError(500, "List alerts failed", err) - } + if err := bus.Dispatch(&dashboardsQuery); err != nil { + return nil, ApiError(500, "List alerts failed", err) } //TODO: should be possible to speed this up with lookup table for _, alert := range alertDTOs { for _, dash := range dashboardsQuery.Result { if alert.DashboardId == dash.Id { - alert.DashbboardUri = "db/" + dash.Slug + alert.Url = dash.GenerateUrl() + break } } } - return Json(200, alertDTOs) + permissionsQuery := models.GetDashboardPermissionsForUserQuery{ + DashboardIds: dashboardIds, + OrgId: c.OrgId, + UserId: c.SignedInUser.UserId, + OrgRole: c.SignedInUser.OrgRole, + } + + if err := bus.Dispatch(&permissionsQuery); err != nil { + return nil, ApiError(500, "List alerts failed", err) + } + + for _, alert := range alertDTOs { + for _, perm := range permissionsQuery.Result { + if alert.DashboardId == perm.DashboardId { + alert.CanEdit = perm.Permission > 1 + } + } + } + + return alertDTOs, nil } // POST /api/alerts/test @@ -155,24 +187,6 @@ func GetAlert(c *middleware.Context) Response { return Json(200, &query.Result) } -// DEL /api/alerts/:id -func DelAlert(c *middleware.Context) Response { - alertId := c.ParamsInt64(":alertId") - - if alertId == 0 { - return ApiError(401, "Failed to parse alertid", nil) - } - - cmd := models.DeleteAlertCommand{AlertId: alertId} - - if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to delete alert", err) - } - - var resp = map[string]interface{}{"alertId": alertId} - return Json(200, resp) -} - func GetAlertNotifiers(c *middleware.Context) Response { return Json(200, alerting.GetNotifiers()) } @@ -267,6 +281,22 @@ func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) R //POST /api/alerts/:alertId/pause func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response { alertId := c.ParamsInt64("alertId") + + query := models.GetAlertByIdQuery{Id: alertId} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Get Alert failed", err) + } + + guardian := guardian.NewDashboardGuardian(query.Result.DashboardId, c.OrgId, c.SignedInUser) + if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { + if err != nil { + return ApiError(500, "Error while checking permissions for Alert", err) + } + + return ApiError(403, "Access denied to this dashboard and alert", nil) + } + cmd := models.PauseAlertCommand{ OrgId: c.OrgId, AlertIds: []int64{alertId}, diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go new file mode 100644 index 00000000000..6b030053e22 --- /dev/null +++ b/pkg/api/alerting_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestAlertingApiEndpoint(t *testing.T) { + Convey("Given an alert in a dashboard with an acl", t, func() { + + singleAlert := &m.Alert{Id: 1, DashboardId: 1, Name: "singlealert"} + + bus.AddHandler("test", func(query *m.GetAlertByIdQuery) error { + query.Result = singleAlert + return nil + }) + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + + aclMockResp := []*m.DashboardAclInfoDTO{} + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + Convey("When user is editor and not in the ACL", func() { + Convey("Should not be able to pause the alert", func() { + cmd := dtos.PauseAlertCommand{ + AlertId: 1, + Paused: true, + } + postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + CallPauseAlert(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is editor and dashboard has default ACL", func() { + aclMockResp = []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + Convey("Should be able to pause the alert", func() { + cmd := dtos.PauseAlertCommand{ + AlertId: 1, + Paused: true, + } + postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + CallPauseAlert(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + }) +} + +func CallPauseAlert(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.PauseAlertCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func postAlertScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PauseAlertCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return PauseAlert(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 0bf95557abc..e3845520795 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -7,7 +7,9 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" ) @@ -51,6 +53,10 @@ func (e *CreateAnnotationError) Error() string { } func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response { + if canSave, err := canSaveByDashboardId(c, cmd.DashboardId); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + repo := annotations.GetRepository() if cmd.Text == "" { @@ -178,6 +184,10 @@ func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Resp repo := annotations.GetRepository() + if resp := canSave(c, repo, annotationId); resp != nil { + return resp + } + item := annotations.Item{ OrgId: c.OrgId, UserId: c.UserId, @@ -228,6 +238,10 @@ func DeleteAnnotationById(c *middleware.Context) Response { repo := annotations.GetRepository() annotationId := c.ParamsInt64(":annotationId") + if resp := canSave(c, repo, annotationId); resp != nil { + return resp + } + err := repo.Delete(&annotations.DeleteParams{ Id: annotationId, }) @@ -243,6 +257,10 @@ func DeleteAnnotationRegion(c *middleware.Context) Response { repo := annotations.GetRepository() regionId := c.ParamsInt64(":regionId") + if resp := canSave(c, repo, regionId); resp != nil { + return resp + } + err := repo.Delete(&annotations.DeleteParams{ RegionId: regionId, }) @@ -253,3 +271,50 @@ func DeleteAnnotationRegion(c *middleware.Context) Response { return ApiSuccess("Annotation region deleted") } + +func canSaveByDashboardId(c *middleware.Context, dashboardId int64) (bool, error) { + if dashboardId == 0 && !c.SignedInUser.HasRole(m.ROLE_EDITOR) { + return false, nil + } + + if dashboardId > 0 { + guardian := guardian.NewDashboardGuardian(dashboardId, c.OrgId, c.SignedInUser) + if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { + return false, err + } + } + + return true, nil +} + +func canSave(c *middleware.Context, repo annotations.Repository, annotationId int64) Response { + items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationId, OrgId: c.OrgId}) + + if err != nil || len(items) == 0 { + return ApiError(500, "Could not find annotation to update", err) + } + + dashboardId := items[0].DashboardId + + if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + return nil +} + +func canSaveByRegionId(c *middleware.Context, repo annotations.Repository, regionId int64) Response { + items, err := repo.Find(&annotations.ItemQuery{RegionId: regionId, OrgId: c.OrgId}) + + if err != nil || len(items) == 0 { + return ApiError(500, "Could not find annotation to update", err) + } + + dashboardId := items[0].DashboardId + + if canSave, err := canSaveByDashboardId(c, dashboardId); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + return nil +} diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go new file mode 100644 index 00000000000..480962d8826 --- /dev/null +++ b/pkg/api/annotations_test.go @@ -0,0 +1,242 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/annotations" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestAnnotationsApiEndpoint(t *testing.T) { + Convey("Given an annotation without a dashboard id", t, func() { + cmd := dtos.PostAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + IsRegion: false, + } + + updateCmd := dtos.UpdateAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + IsRegion: false, + } + + Convey("When user is an Org Viewer", func() { + role := m.ROLE_VIEWER + Convey("Should not be allowed to save an annotation", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationById + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationRegion + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is an Org Editor", func() { + role := m.ROLE_EDITOR + Convey("Should be able to save an annotation", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationById + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationRegion + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + }) + + Convey("Given an annotation with a dashboard id and the dashboard does not have an acl", t, func() { + cmd := dtos.PostAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + IsRegion: false, + DashboardId: 1, + PanelId: 1, + } + + updateCmd := dtos.UpdateAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + IsRegion: false, + Id: 1, + } + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + + aclMockResp := []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + Convey("When user is an Org Viewer", func() { + role := m.ROLE_VIEWER + Convey("Should not be allowed to save an annotation", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationById + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationRegion + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is an Org Editor", func() { + role := m.ROLE_EDITOR + Convey("Should be able to save an annotation", func() { + postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationById + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/region/1", "/api/annotations/region/:regionId", role, func(sc *scenarioContext) { + sc.handlerFunc = DeleteAnnotationRegion + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + }) +} + +type fakeAnnotationsRepo struct { +} + +func (repo *fakeAnnotationsRepo) Delete(params *annotations.DeleteParams) error { + return nil +} +func (repo *fakeAnnotationsRepo) Save(item *annotations.Item) error { + item.Id = 1 + return nil +} +func (repo *fakeAnnotationsRepo) Update(item *annotations.Item) error { + return nil +} +func (repo *fakeAnnotationsRepo) Find(query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) { + annotations := []*annotations.ItemDTO{{Id: 1}} + return annotations, nil +} + +var fakeAnnoRepo *fakeAnnotationsRepo + +func postAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PostAnnotationsCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return PostAnnotation(c, cmd) + }) + + fakeAnnoRepo = &fakeAnnotationsRepo{} + annotations.SetRepository(fakeAnnoRepo) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func putAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateAnnotationsCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return UpdateAnnotation(c, cmd) + }) + + fakeAnnoRepo = &fakeAnnotationsRepo{} + annotations.SetRepository(fakeAnnoRepo) + + sc.m.Put(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index ea082ff4741..752af7602f5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -15,6 +15,8 @@ func (hs *HttpServer) registerRoutes() { reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN) + redirectFromLegacyDashboardUrl := middleware.RedirectFromLegacyDashboardUrl() + redirectFromLegacyDashboardSoloUrl := middleware.RedirectFromLegacyDashboardSoloUrl() quota := middleware.Quota bind := binding.Bind @@ -63,9 +65,13 @@ func (hs *HttpServer) registerRoutes() { r.Get("/plugins/:id/edit", reqSignedIn, Index) r.Get("/plugins/:id/page/:page", reqSignedIn, Index) - r.Get("/dashboard/*", reqSignedIn, Index) + r.Get("/d/:uid/:slug", reqSignedIn, Index) + r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardUrl, Index) + r.Get("/dashboard/script/*", reqSignedIn, Index) r.Get("/dashboard-solo/snapshot/*", Index) - r.Get("/dashboard-solo/*", reqSignedIn, Index) + r.Get("/d-solo/:uid/:slug", reqSignedIn, Index) + r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloUrl, Index) + r.Get("/dashboard-solo/script/*", reqSignedIn, Index) r.Get("/import/dashboard", reqSignedIn, Index) r.Get("/dashboards/", reqSignedIn, Index) r.Get("/dashboards/*", reqSignedIn, Index) @@ -144,13 +150,13 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Group("/teams", func(teamsRoute RouteRegister) { teamsRoute.Get("/:teamId", wrap(GetTeamById)) teamsRoute.Get("/search", wrap(SearchTeams)) - teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) - teamsRoute.Delete("/:teamId", wrap(DeleteTeamById)) - teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) - teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember)) - }, reqOrgAdmin) + teamsRoute.Post("/", quota("teams"), reqOrgAdmin, bind(m.CreateTeamCommand{}), wrap(CreateTeam)) + teamsRoute.Put("/:teamId", reqOrgAdmin, bind(m.UpdateTeamCommand{}), wrap(UpdateTeam)) + teamsRoute.Delete("/:teamId", reqOrgAdmin, wrap(DeleteTeamById)) + teamsRoute.Get("/:teamId/members", reqOrgAdmin, wrap(GetTeamMembers)) + teamsRoute.Post("/:teamId/members", reqOrgAdmin, quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember)) + teamsRoute.Delete("/:teamId/members/:userId", reqOrgAdmin, wrap(RemoveTeamMember)) + }) // org information available to all users. apiRoute.Group("/org", func(orgRoute RouteRegister) { @@ -242,20 +248,25 @@ func (hs *HttpServer) registerRoutes() { // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { + dashboardRoute.Get("/uid/:uid", wrap(GetDashboard)) + dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUid)) + dashboardRoute.Get("/db/:slug", wrap(GetDashboard)) - dashboardRoute.Delete("/db/:slug", reqEditorRole, wrap(DeleteDashboard)) + dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard)) dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff)) - dashboardRoute.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard)) + dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), wrap(PostDashboard)) dashboardRoute.Get("/home", wrap(GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard)) + dashboardRoute.Get("/folders", wrap(GetFoldersForSignedInUser)) + dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) { dashIdRoute.Get("/versions", wrap(GetDashboardVersions)) dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) - dashIdRoute.Post("/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) + dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { aclRoute.Get("/", wrap(GetDashboardAclList)) @@ -317,8 +328,8 @@ func (hs *HttpServer) registerRoutes() { annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById)) annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation)) annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion)) - annotationsRoute.Post("/graphite", bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation)) - }, reqEditorRole) + annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation)) + }) // error test r.Get("/metrics/error", wrap(GenerateError)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go new file mode 100644 index 00000000000..33fc1688603 --- /dev/null +++ b/pkg/api/common_test.go @@ -0,0 +1,105 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + + "github.com/go-macaron/session" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + macaron "gopkg.in/macaron.v1" + + . "github.com/smartystreets/goconvey/convey" +) + +func loggedInUserScenario(desc string, url string, fn scenarioFunc) { + loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn) +} + +func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + if sc.handlerFunc != nil { + return sc.handlerFunc(sc.context) + } + + return nil + }) + + switch method { + case "GET": + sc.m.Get(routePattern, sc.defaultHandler) + case "DELETE": + sc.m.Delete(routePattern, sc.defaultHandler) + } + + fn(sc) + }) +} + +func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + So(err, ShouldBeNil) + sc.req = req + + return sc +} + +func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + q := req.URL.Query() + for k, v := range queryParams { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + So(err, ShouldBeNil) + sc.req = req + + return sc +} + +type scenarioContext struct { + m *macaron.Macaron + context *middleware.Context + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string +} + +func (sc *scenarioContext) exec() { + sc.m.ServeHTTP(sc.resp, sc.req) +} + +type scenarioFunc func(c *scenarioContext) +type handlerFunc func(c *middleware.Context) Response + +func setupScenarioContext(url string) *scenarioContext { + sc := &scenarioContext{ + url: url, + } + viewsPath, _ := filepath.Abs("../../public/views") + + sc.m = macaron.New() + sc.m.Use(macaron.Renderer(macaron.RenderOptions{ + Directory: viewsPath, + Delims: macaron.Delims{Left: "[[", Right: "]]"}, + })) + + sc.m.Use(middleware.GetContextHandler()) + sc.m.Use(middleware.Sessioner(&session.Options{})) + + return sc +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 28a955d5abf..100fd41aad5 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -38,20 +38,19 @@ func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error) func dashboardGuardianResponse(err error) Response { if err != nil { return ApiError(500, "Error while checking dashboard permissions", err) - } else { - return ApiError(403, "Access denied to this dashboard", nil) } + + return ApiError(403, "Access denied to this dashboard", nil) } func GetDashboard(c *middleware.Context) Response { - dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0) + dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, c.Params(":uid")) if rsp != nil { return rsp } guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) if canView, err := guardian.CanView(); err != nil || !canView { - fmt.Printf("%v", err) return dashboardGuardianResponse(err) } @@ -89,7 +88,8 @@ func GetDashboard(c *middleware.Context) Response { HasAcl: dash.HasAcl, IsFolder: dash.IsFolder, FolderId: dash.FolderId, - FolderTitle: "Root", + Url: dash.GetUrl(), + FolderTitle: "General", } // lookup folder title @@ -99,6 +99,7 @@ func GetDashboard(c *middleware.Context) Response { return ApiError(500, "Dashboard folder could not be read", err) } meta.FolderTitle = query.Result.Title + meta.FolderUrl = query.Result.GetUrl() } // make sure db version is in sync with json model version @@ -124,8 +125,15 @@ func getUserLogin(userId int64) string { } } -func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Response) { - query := m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId} +func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dashboard, Response) { + var query m.GetDashboardQuery + + if len(uid) > 0 { + query = m.GetDashboardQuery{Uid: uid, Id: id, OrgId: orgId} + } else { + query = m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId} + } + if err := bus.Dispatch(&query); err != nil { return nil, ApiError(404, "Dashboard not found", err) } @@ -133,7 +141,37 @@ func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Respo } func DeleteDashboard(c *middleware.Context) Response { - dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0) + query := m.GetDashboardsBySlugQuery{OrgId: c.OrgId, Slug: c.Params(":slug")} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to retrieve dashboards by slug", err) + } + + if len(query.Result) > 1 { + return Json(412, util.DynMap{"status": "multiple-slugs-exists", "message": m.ErrDashboardsWithSameSlugExists.Error()}) + } + + dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, "") + if rsp != nil { + return rsp + } + + guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + return dashboardGuardianResponse(err) + } + + cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id} + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "Failed to delete dashboard", err) + } + + var resp = map[string]interface{}{"title": dash.Title} + return Json(200, resp) +} + +func DeleteDashboardByUid(c *middleware.Context) Response { + dash, rsp := getDashboardHelper(c.OrgId, "", 0, c.Params(":uid")) if rsp != nil { return rsp } @@ -158,7 +196,14 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { dash := cmd.GetDashboardModel() - guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) + dashId := dash.Id + + // if new dashboard, use parent folder permissions instead + if dashId == 0 { + dashId = cmd.FolderId + } + + guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return dashboardGuardianResponse(err) } @@ -201,7 +246,10 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { } if err != nil { - if err == m.ErrDashboardWithSameNameExists { + if err == m.ErrDashboardWithSameUIDExists { + return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()}) + } + if err == m.ErrDashboardWithSameNameInFolderExists { return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()}) } if err == m.ErrDashboardVersionMismatch { @@ -225,8 +273,17 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { return ApiError(500, "Invalid alert data. Cannot save dashboard", err) } + dashboard.IsFolder = dash.IsFolder + c.TimeRequest(metrics.M_Api_Dashboard_Save) - return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version, "id": dashboard.Id}) + return Json(200, util.DynMap{ + "status": "success", + "slug": dashboard.Slug, + "version": dashboard.Version, + "id": dashboard.Id, + "uid": dashboard.Uid, + "url": dashboard.GetUrl(), + }) } func GetHomeDashboard(c *middleware.Context) Response { @@ -236,10 +293,11 @@ func GetHomeDashboard(c *middleware.Context) Response { } if prefsQuery.Result.HomeDashboardId != 0 { - slugQuery := m.GetDashboardSlugByIdQuery{Id: prefsQuery.Result.HomeDashboardId} + slugQuery := m.GetDashboardRefByIdQuery{Id: prefsQuery.Result.HomeDashboardId} err := bus.Dispatch(&slugQuery) if err == nil { - dashRedirect := dtos.DashboardRedirect{RedirectUri: "db/" + slugQuery.Result} + url := m.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug) + dashRedirect := dtos.DashboardRedirect{RedirectUri: url} return Json(200, &dashRedirect) } else { log.Warn("Failed to get slug from database, %s", err.Error()) @@ -255,7 +313,7 @@ func GetHomeDashboard(c *middleware.Context) Response { dash := dtos.DashboardFullWithMeta{} dash.Meta.IsHome = true dash.Meta.CanEdit = c.SignedInUser.HasRole(m.ROLE_EDITOR) - dash.Meta.FolderTitle = "Root" + dash.Meta.FolderTitle = "General" jsonParser := json.NewDecoder(file) if err := jsonParser.Decode(&dash.Dashboard); err != nil { @@ -393,7 +451,7 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff // RestoreDashboardVersion restores a dashboard to the given version. func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response { - dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId")) + dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"), "") if rsp != nil { return rsp } @@ -416,6 +474,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard saveCmd.UserId = c.UserId saveCmd.Dashboard = version.Data saveCmd.Dashboard.Set("version", dash.Version) + saveCmd.Dashboard.Set("uid", dash.Uid) saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version) return PostDashboard(c, saveCmd) @@ -431,3 +490,19 @@ func GetDashboardTags(c *middleware.Context) { c.JSON(200, query.Result) } + +func GetFoldersForSignedInUser(c *middleware.Context) Response { + title := c.Query("query") + query := m.GetFoldersForSignedInUserQuery{ + OrgId: c.OrgId, + SignedInUser: c.SignedInUser, + Title: title, + } + + err := bus.Dispatch(&query) + if err != nil { + return ApiError(500, "Failed to get folders from database", err) + } + + return Json(200, query.Result) +} diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_acl.go index 88cc74b9d1c..b5d912d25f1 100644 --- a/pkg/api/dashboard_acl.go +++ b/pkg/api/dashboard_acl.go @@ -24,6 +24,12 @@ func GetDashboardAclList(c *middleware.Context) Response { return ApiError(500, "Failed to get dashboard acl", err) } + for _, perm := range acl { + if perm.Slug != "" { + perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) + } + } + return Json(200, acl) } @@ -51,6 +57,14 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom }) } + if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { + if err != nil { + return ApiError(500, "Error while checking dashboard permissions", err) + } + + return ApiError(403, "Cannot remove own admin permission for a folder", nil) + } + if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrDashboardAclInfoMissing || err == m.ErrDashboardPermissionDashboardEmpty { return ApiError(409, err.Error(), err) @@ -70,6 +84,14 @@ func DeleteDashboardAcl(c *middleware.Context) Response { return dashboardGuardianResponse(err) } + if okToDelete, err := guardian.CheckPermissionBeforeRemove(m.PERMISSION_ADMIN, aclId); err != nil || !okToDelete { + if err != nil { + return ApiError(500, "Error while checking dashboard permissions", err) + } + + return ApiError(403, "Cannot remove own admin permission for a folder", nil) + } + cmd := m.RemoveDashboardAclCommand{OrgId: c.OrgId, AclId: aclId} if err := bus.Dispatch(&cmd); err != nil { return ApiError(500, "Failed to delete permission for user", err) diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go index e22e625dcf9..467045e360a 100644 --- a/pkg/api/dashboard_acl_test.go +++ b/pkg/api/dashboard_acl_test.go @@ -3,8 +3,10 @@ package api import ( "testing" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" @@ -37,6 +39,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) { return nil }) + // This tests four scenarios: + // 1. user is an org admin + // 2. user is an org editor AND has been granted admin permission for the dashboard + // 3. user is an org viewer AND has been granted edit permission for the dashboard + // 4. user is an org editor AND has no permissions for the dashboard + Convey("When user is org admin", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { Convey("Should be able to access ACL", func() { @@ -54,9 +62,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and has admin permission in the ACL", func() { + Convey("When user is org editor and has admin permission in the ACL", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) Convey("Should be able to access ACL", func() { sc.handlerFunc = GetDashboardAclList @@ -67,7 +75,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { - mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { return nil @@ -81,6 +89,52 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/6", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + + bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { + return nil + }) + + Convey("Should not be able to delete their own Admin permission", func() { + sc.handlerFunc = DeleteDashboardAcl + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("Should not be able to downgrade their own Admin permission", func() { + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: TestUserID, Permission: m.PERMISSION_EDIT}, + }, + } + + postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + + CallPostAcl(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + Convey("Should be able to update permissions", func() { + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: TestUserID, Permission: m.PERMISSION_ADMIN}, + {UserId: 2, Permission: m.PERMISSION_EDIT}, + }, + } + + postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) + + CallPostAcl(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + Convey("When user is a member of a team in the ACL with admin permission", func() { loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardsId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { teamResp = append(teamResp, &m.Team{Id: 2, OrgId: 1, Name: "UG2"}) @@ -99,11 +153,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and has edit permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { + Convey("When user is org viewer and has edit permission in the ACL", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) - Convey("Should not be able to access ACL", func() { + // Getting the permissions is an Admin permission + Convey("Should not be able to get list of permissions from ACL", func() { sc.handlerFunc = GetDashboardAclList sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() @@ -111,7 +166,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_VIEWER, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { @@ -127,7 +182,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and not in the ACL", func() { + Convey("When user is org editor and not in the ACL", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { Convey("Should not be able to access ACL", func() { @@ -172,3 +227,32 @@ func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardA return dtos } + +func CallPostAcl(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func postAclScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return UpdateDashboardAcl(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index ed76544b9b8..e80b3cad4dc 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -2,12 +2,8 @@ package api import ( "encoding/json" - "path/filepath" "testing" - macaron "gopkg.in/macaron.v1" - - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" @@ -42,6 +38,10 @@ func (repo *fakeDashboardRepo) GetProvisionedDashboardData(name string) ([]*m.Da var fakeRepo *fakeDashboardRepo +// This tests two main scenarios. If a user has access to execute an action on a dashboard: +// 1. and the dashboard is in a folder which does not have an acl +// 2. and the dashboard is in a folder which does have an acl + func TestDashboardApiEndpoint(t *testing.T) { Convey("Given a dashboard with a parent folder which does not have an acl", t, func() { fakeDash := m.NewDashboard("Child dash") @@ -49,8 +49,17 @@ func TestDashboardApiEndpoint(t *testing.T) { fakeDash.FolderId = 1 fakeDash.HasAcl = false + bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error { + dashboards := []*m.Dashboard{fakeDash} + query.Result = dashboards + return nil + }) + + var getDashboardQueries []*m.GetDashboardQuery + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { query.Result = fakeDash + getDashboardQueries = append(getDashboardQueries, query) return nil }) @@ -80,12 +89,20 @@ func TestDashboardApiEndpoint(t *testing.T) { }), } + // This tests two scenarios: + // 1. user is an org viewer + // 2. user is an org editor + Convey("When user is an Org Viewer", func() { role := m.ROLE_VIEWER - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { dash := GetDashboardShouldReturn200(sc) + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + Convey("Should not be able to edit or save dashboard", func() { So(dash.Meta.CanEdit, ShouldBeFalse) So(dash.Meta.CanSave, ShouldBeFalse) @@ -93,9 +110,36 @@ func TestDashboardApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) + + Convey("Should not be able to edit or save dashboard", func() { + So(dash.Meta.CanEdit, ShouldBeFalse) + So(dash.Meta.CanSave, ShouldBeFalse) + So(dash.Meta.CanAdmin, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { CallDeleteDashboard(sc) So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + CallDeleteDashboardByUid(sc) + So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) }) loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -117,9 +161,13 @@ func TestDashboardApiEndpoint(t *testing.T) { Convey("When user is an Org Editor", func() { role := m.ROLE_EDITOR - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { dash := GetDashboardShouldReturn200(sc) + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + Convey("Should be able to edit or save dashboard", func() { So(dash.Meta.CanEdit, ShouldBeTrue) So(dash.Meta.CanSave, ShouldBeTrue) @@ -127,9 +175,36 @@ func TestDashboardApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) + + Convey("Should be able to edit or save dashboard", func() { + So(dash.Meta.CanEdit, ShouldBeTrue) + So(dash.Meta.CanSave, ShouldBeTrue) + So(dash.Meta.CanAdmin, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { CallDeleteDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) + + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + CallDeleteDashboardByUid(sc) + So(sc.resp.Code, ShouldEqual, 200) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) }) loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -143,8 +218,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 200) + CallPostDashboardShouldReturnSuccess(sc) }) Convey("When saving a dashboard folder in another folder", func() { @@ -178,6 +252,12 @@ func TestDashboardApiEndpoint(t *testing.T) { fakeDash.HasAcl = true setting.ViewersCanEdit = false + bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error { + dashboards := []*m.Dashboard{fakeDash} + query.Result = dashboards + return nil + }) + aclMockResp := []*m.DashboardAclInfoDTO{ { DashboardId: 1, @@ -191,8 +271,11 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) + var getDashboardQueries []*m.GetDashboardQuery + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { query.Result = fakeDash + getDashboardQueries = append(getDashboardQueries, query) return nil }) @@ -210,21 +293,59 @@ func TestDashboardApiEndpoint(t *testing.T) { }), } + // This tests six scenarios: + // 1. user is an org viewer AND has no permissions for this dashboard + // 2. user is an org editor AND has no permissions for this dashboard + // 3. user is an org viewer AND has been granted edit permission for the dashboard + // 4. user is an org viewer AND all viewers have edit permission for this dashboard + // 5. user is an org viewer AND has been granted an admin permission + // 6. user is an org editor AND has been granted a view permission + Convey("When user is an Org Viewer and has no permissions for this dashboard", func() { role := m.ROLE_VIEWER - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { sc.handlerFunc = GetDashboard sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + Convey("Should be denied access", func() { So(sc.resp.Code, ShouldEqual, 403) }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) + + Convey("Should be denied access", func() { + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { CallDeleteDashboard(sc) So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + CallDeleteDashboardByUid(sc) + So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) }) loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -246,18 +367,48 @@ func TestDashboardApiEndpoint(t *testing.T) { Convey("When user is an Org Editor and has no permissions for this dashboard", func() { role := m.ROLE_EDITOR - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { sc.handlerFunc = GetDashboard sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + Convey("Should be denied access", func() { So(sc.resp.Code, ShouldEqual, 403) }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) + + Convey("Should be denied access", func() { + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { CallDeleteDashboard(sc) So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + CallDeleteDashboardByUid(sc) + So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) }) loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -288,9 +439,13 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { dash := GetDashboardShouldReturn200(sc) + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + Convey("Should be able to get dashboard with edit rights", func() { So(dash.Meta.CanEdit, ShouldBeTrue) So(dash.Meta.CanSave, ShouldBeTrue) @@ -298,9 +453,36 @@ func TestDashboardApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) + + Convey("Should be able to get dashboard with edit rights", func() { + So(dash.Meta.CanEdit, ShouldBeTrue) + So(dash.Meta.CanSave, ShouldBeTrue) + So(dash.Meta.CanAdmin, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { CallDeleteDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) + + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + CallDeleteDashboardByUid(sc) + So(sc.resp.Code, ShouldEqual, 200) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) }) loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -314,8 +496,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 200) + CallPostDashboardShouldReturnSuccess(sc) }) }) @@ -332,9 +513,13 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { dash := GetDashboardShouldReturn200(sc) + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + Convey("Should be able to get dashboard with edit rights but can save should be false", func() { So(dash.Meta.CanEdit, ShouldBeTrue) So(dash.Meta.CanSave, ShouldBeFalse) @@ -342,9 +527,36 @@ func TestDashboardApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) + + Convey("Should be able to get dashboard with edit rights but can save should be false", func() { + So(dash.Meta.CanEdit, ShouldBeTrue) + So(dash.Meta.CanSave, ShouldBeFalse) + So(dash.Meta.CanAdmin, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { CallDeleteDashboard(sc) So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + CallDeleteDashboardByUid(sc) + So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) }) }) @@ -360,9 +572,13 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { dash := GetDashboardShouldReturn200(sc) + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + Convey("Should be able to get dashboard with edit rights", func() { So(dash.Meta.CanEdit, ShouldBeTrue) So(dash.Meta.CanSave, ShouldBeTrue) @@ -370,9 +586,36 @@ func TestDashboardApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) + + Convey("Should be able to get dashboard with edit rights", func() { + So(dash.Meta.CanEdit, ShouldBeTrue) + So(dash.Meta.CanSave, ShouldBeTrue) + So(dash.Meta.CanAdmin, ShouldBeTrue) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { CallDeleteDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) + + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + CallDeleteDashboardByUid(sc) + So(sc.resp.Code, ShouldEqual, 200) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) }) loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -386,8 +629,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) { - CallPostDashboard(sc) - So(sc.resp.Code, ShouldEqual, 200) + CallPostDashboardShouldReturnSuccess(sc) }) }) @@ -403,18 +645,48 @@ func TestDashboardApiEndpoint(t *testing.T) { return nil }) - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { dash := GetDashboardShouldReturn200(sc) + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + Convey("Should not be able to edit or save dashboard", func() { So(dash.Meta.CanEdit, ShouldBeFalse) So(dash.Meta.CanSave, ShouldBeFalse) }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + dash := GetDashboardShouldReturn200(sc) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) + + Convey("Should not be able to edit or save dashboard", func() { + So(dash.Meta.CanEdit, ShouldBeFalse) + So(dash.Meta.CanSave, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { CallDeleteDashboard(sc) So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by slug", func() { + So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash") + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { + CallDeleteDashboardByUid(sc) + So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup dashboard by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi") + }) }) loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -433,6 +705,37 @@ func TestDashboardApiEndpoint(t *testing.T) { }) }) }) + + Convey("Given two dashboards with the same title in different folders", t, func() { + dashOne := m.NewDashboard("dash") + dashOne.Id = 2 + dashOne.FolderId = 1 + dashOne.HasAcl = false + + dashTwo := m.NewDashboard("dash") + dashTwo.Id = 4 + dashTwo.FolderId = 3 + dashTwo.HasAcl = false + + bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error { + dashboards := []*m.Dashboard{dashOne, dashTwo} + query.Result = dashboards + return nil + }) + + role := m.ROLE_EDITOR + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { + CallDeleteDashboard(sc) + + Convey("Should result in 412 Precondition failed", func() { + So(sc.resp.Code, ShouldEqual, 412) + result := sc.ToJson() + So(result.Get("status").MustString(), ShouldEqual, "multiple-slugs-exists") + So(result.Get("message").MustString(), ShouldEqual, m.ErrDashboardsWithSameSlugExists.Error()) + }) + }) + }) } func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { @@ -477,6 +780,15 @@ func CallDeleteDashboard(sc *scenarioContext) { sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() } +func CallDeleteDashboardByUid(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error { + return nil + }) + + sc.handlerFunc = DeleteDashboardByUid + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() +} + func CallPostDashboard(sc *scenarioContext) { bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error { return nil @@ -494,24 +806,23 @@ func CallPostDashboard(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() } +func CallPostDashboardShouldReturnSuccess(sc *scenarioContext) { + CallPostDashboard(sc) + + So(sc.resp.Code, ShouldEqual, 200) + result := sc.ToJson() + So(result.Get("status").MustString(), ShouldEqual, "success") + So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0) + So(result.Get("uid").MustString(), ShouldNotBeNil) + So(result.Get("slug").MustString(), ShouldNotBeNil) + So(result.Get("url").MustString(), ShouldNotBeNil) +} + func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) { Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() - sc := &scenarioContext{ - url: url, - } - viewsPath, _ := filepath.Abs("../../public/views") - - sc.m = macaron.New() - sc.m.Use(macaron.Renderer(macaron.RenderOptions{ - Directory: viewsPath, - Delims: macaron.Delims{Left: "[[", Right: "]]"}, - })) - - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) - + sc := setupScenarioContext(url) sc.defaultHandler = wrap(func(c *middleware.Context) Response { sc.context = c sc.context.UserId = TestUserID @@ -529,3 +840,10 @@ func postDashboardScenario(desc string, url string, routePattern string, role m. fn(sc) }) } + +func (sc *scenarioContext) ToJson() *simplejson.Json { + var result *simplejson.Json + err := json.NewDecoder(sc.resp.Body).Decode(&result) + So(err, ShouldBeNil) + return result +} diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 72336693363..490393727d6 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -2,17 +2,11 @@ package api import ( "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" "testing" "github.com/grafana/grafana/pkg/models" - macaron "gopkg.in/macaron.v1" - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" . "github.com/smartystreets/goconvey/convey" ) @@ -54,88 +48,3 @@ func TestDataSourcesProxy(t *testing.T) { }) }) } - -func loggedInUserScenario(desc string, url string, fn scenarioFunc) { - loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn) -} - -func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { - Convey(desc+" "+url, func() { - defer bus.ClearBusHandlers() - - sc := &scenarioContext{ - url: url, - } - viewsPath, _ := filepath.Abs("../../public/views") - - sc.m = macaron.New() - sc.m.Use(macaron.Renderer(macaron.RenderOptions{ - Directory: viewsPath, - Delims: macaron.Delims{Left: "[[", Right: "]]"}, - })) - - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) - - sc.defaultHandler = wrap(func(c *middleware.Context) Response { - sc.context = c - sc.context.UserId = TestUserID - sc.context.OrgId = TestOrgID - sc.context.OrgRole = role - if sc.handlerFunc != nil { - return sc.handlerFunc(sc.context) - } - - return nil - }) - - switch method { - case "GET": - sc.m.Get(routePattern, sc.defaultHandler) - case "DELETE": - sc.m.Delete(routePattern, sc.defaultHandler) - } - - fn(sc) - }) -} - -func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { - sc.resp = httptest.NewRecorder() - req, err := http.NewRequest(method, url, nil) - So(err, ShouldBeNil) - sc.req = req - - return sc -} - -func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { - sc.resp = httptest.NewRecorder() - req, err := http.NewRequest(method, url, nil) - q := req.URL.Query() - for k, v := range queryParams { - q.Add(k, v) - } - req.URL.RawQuery = q.Encode() - So(err, ShouldBeNil) - sc.req = req - - return sc -} - -type scenarioContext struct { - m *macaron.Macaron - context *middleware.Context - resp *httptest.ResponseRecorder - handlerFunc handlerFunc - defaultHandler macaron.Handler - req *http.Request - url string -} - -func (sc *scenarioContext) exec() { - sc.m.ServeHTTP(sc.resp, sc.req) -} - -type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *middleware.Context) Response diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index 4285ebc89cc..d30f2697f3f 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -19,7 +19,8 @@ type AlertRule struct { EvalDate time.Time `json:"evalDate"` EvalData *simplejson.Json `json:"evalData"` ExecutionError string `json:"executionError"` - DashbboardUri string `json:"dashboardUri"` + Url string `json:"url"` + CanEdit bool `json:"canEdit"` } type AlertNotification struct { diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 0be0537527b..e4c66aebbda 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -16,6 +16,7 @@ type DashboardMeta struct { CanAdmin bool `json:"canAdmin"` CanStar bool `json:"canStar"` Slug string `json:"slug"` + Url string `json:"url"` Expires time.Time `json:"expires"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` @@ -26,6 +27,7 @@ type DashboardMeta struct { IsFolder bool `json:"isFolder"` FolderId int64 `json:"folderId"` FolderTitle string `json:"folderTitle"` + FolderUrl string `json:"folderUrl"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 0366b9aedad..b911780913d 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -162,6 +162,10 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron { hs.mapStatic(m, setting.StaticRootPath, "", "public") hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt") + if setting.ImageUploadProvider == "local" { + hs.mapStatic(m, setting.ImagesDir, "", "/public/img/attachments") + } + m.Use(macaron.Renderer(macaron.RenderOptions{ Directory: path.Join(setting.StaticRootPath, "views"), IndentJSON: macaron.Env != macaron.PROD, diff --git a/pkg/api/index.go b/pkg/api/index.go index 1b836356189..5beecefab88 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -102,8 +102,8 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { } dashboardChildNavs := []*dtos.NavLink{ - {Text: "Home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true}, - {Divider: true, HideFromTabs: true}, + {Text: "Home", Id: "home", Url: setting.AppSubUrl + "/", Icon: "gicon gicon-home", HideFromTabs: true}, + {Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true}, {Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "gicon gicon-manage"}, {Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "gicon gicon-playlists"}, {Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "gicon gicon-snapshots"}, @@ -261,7 +261,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { if c.IsGrafanaAdmin { cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{ - Divider: true, HideFromTabs: true, + Divider: true, HideFromTabs: true, Id: "admin-divider", Text: "Text", }) cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{ Text: "Server Admin", diff --git a/pkg/api/login.go b/pkg/api/login.go index ebfe672f825..b6855af7baf 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -102,12 +102,13 @@ func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response { } authQuery := login.LoginUserQuery{ - Username: cmd.User, - Password: cmd.Password, + Username: cmd.User, + Password: cmd.Password, + IpAddress: c.Req.RemoteAddr, } if err := bus.Dispatch(&authQuery); err != nil { - if err == login.ErrInvalidCredentials { + if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts { return ApiError(401, "Invalid username or password", err) } diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 38aff2ac2b4..341ff212f10 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -29,7 +29,7 @@ var ( ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") ErrUsersQuotaReached = errors.New("Users quota reached") ErrNoEmail = errors.New("Login provider didn't return an email address") - oauthLogger = log.New("oauth.login") + oauthLogger = log.New("oauth") ) func GenStateString() string { @@ -40,14 +40,14 @@ func GenStateString() string { func OAuthLogin(ctx *middleware.Context) { if setting.OAuthService == nil { - ctx.Handle(404, "login.OAuthLogin(oauth service not enabled)", nil) + ctx.Handle(404, "OAuth not enabled", nil) return } name := ctx.Params(":name") connect, ok := social.SocialMap[name] if !ok { - ctx.Handle(404, "login.OAuthLogin(social login not enabled)", errors.New(name)) + ctx.Handle(404, fmt.Sprintf("No OAuth with name %s configured", name), nil) return } @@ -96,7 +96,9 @@ func OAuthLogin(ctx *middleware.Context) { if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" || setting.OAuthService.OAuthInfos[name].TlsClientKey != "" { cert, err := tls.LoadX509KeyPair(setting.OAuthService.OAuthInfos[name].TlsClientCert, setting.OAuthService.OAuthInfos[name].TlsClientKey) if err != nil { - log.Fatal(1, "Failed to setup TlsClientCert", "oauth provider", name, "error", err) + ctx.Logger.Error("Failed to setup TlsClientCert", "oauth", name, "error", err) + ctx.Handle(500, "login.OAuthLogin(Failed to setup TlsClientCert)", nil) + return } tr.TLSClientConfig.Certificates = append(tr.TLSClientConfig.Certificates, cert) @@ -105,7 +107,9 @@ func OAuthLogin(ctx *middleware.Context) { if setting.OAuthService.OAuthInfos[name].TlsClientCa != "" { caCert, err := ioutil.ReadFile(setting.OAuthService.OAuthInfos[name].TlsClientCa) if err != nil { - log.Fatal(1, "Failed to setup TlsClientCa", "oauth provider", name, "error", err) + ctx.Logger.Error("Failed to setup TlsClientCa", "oauth", name, "error", err) + ctx.Handle(500, "login.OAuthLogin(Failed to setup TlsClientCa)", nil) + return } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) @@ -124,13 +128,13 @@ func OAuthLogin(ctx *middleware.Context) { // token.TokenType was defaulting to "bearer", which is out of spec, so we explicitly set to "Bearer" token.TokenType = "Bearer" - ctx.Logger.Debug("OAuthLogin Got token") + oauthLogger.Debug("OAuthLogin Got token", "token", token) // set up oauth2 client client := connect.Client(oauthCtx, token) // get user info - userInfo, err := connect.UserInfo(client) + userInfo, err := connect.UserInfo(client, token) if err != nil { if sErr, ok := err.(*social.Error); ok { redirectWithError(ctx, sErr) @@ -140,7 +144,7 @@ func OAuthLogin(ctx *middleware.Context) { return } - ctx.Logger.Debug("OAuthLogin got user info", "userInfo", userInfo) + oauthLogger.Debug("OAuthLogin got user info", "userInfo", userInfo) // validate that we got at least an email address if userInfo.Email == "" { @@ -205,8 +209,7 @@ func OAuthLogin(ctx *middleware.Context) { } func redirectWithError(ctx *middleware.Context, err error, v ...interface{}) { - ctx.Logger.Info(err.Error(), v...) - // TODO: we can use the flash storage here once it's implemented + ctx.Logger.Error(err.Error(), v...) ctx.Session.Set("loginError", err.Error()) ctx.Redirect(setting.AppSubUrl + "/login") } diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go index 383d2c6d311..52a31f9f606 100644 --- a/pkg/components/imguploader/imguploader.go +++ b/pkg/components/imguploader/imguploader.go @@ -88,6 +88,8 @@ func NewImageUploader() (ImageUploader, error) { container_name := azureBlobSec.Key("container_name").MustString("") return NewAzureBlobUploader(account_name, account_key, container_name), nil + case "local": + return NewLocalImageUploader() } if setting.ImageUploadProvider != "" { diff --git a/pkg/components/imguploader/imguploader_test.go b/pkg/components/imguploader/imguploader_test.go index d5008c9ae9f..b0311dac975 100644 --- a/pkg/components/imguploader/imguploader_test.go +++ b/pkg/components/imguploader/imguploader_test.go @@ -143,5 +143,23 @@ func TestImageUploaderFactory(t *testing.T) { So(original.container_name, ShouldEqual, "container_name") }) }) + + Convey("Local uploader", func() { + var err error + + setting.NewConfigContext(&setting.CommandLineArgs{ + HomePath: "../../../", + }) + + setting.ImageUploadProvider = "local" + + uploader, err := NewImageUploader() + + So(err, ShouldBeNil) + original, ok := uploader.(*LocalUploader) + + So(ok, ShouldBeTrue) + So(original, ShouldNotBeNil) + }) }) } diff --git a/pkg/components/imguploader/localuploader.go b/pkg/components/imguploader/localuploader.go new file mode 100644 index 00000000000..022d67122a7 --- /dev/null +++ b/pkg/components/imguploader/localuploader.go @@ -0,0 +1,22 @@ +package imguploader + +import ( + "context" + "path" + "path/filepath" + + "github.com/grafana/grafana/pkg/setting" +) + +type LocalUploader struct { +} + +func (u *LocalUploader) Upload(ctx context.Context, imageOnDiskPath string) (string, error) { + filename := filepath.Base(imageOnDiskPath) + image_url := setting.ToAbsUrl(path.Join("public/img/attachments", filename)) + return image_url, nil +} + +func NewLocalImageUploader() (*LocalUploader, error) { + return &LocalUploader{}, nil +} diff --git a/pkg/components/imguploader/localuploader_test.go b/pkg/components/imguploader/localuploader_test.go new file mode 100644 index 00000000000..0d8c0df0e57 --- /dev/null +++ b/pkg/components/imguploader/localuploader_test.go @@ -0,0 +1,18 @@ +package imguploader + +import ( + "context" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestUploadToLocal(t *testing.T) { + Convey("[Integration test] for external_image_store.local", t, func() { + localUploader, _ := NewLocalImageUploader() + path, err := localUploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png") + + So(err, ShouldBeNil) + So(path, ShouldContainSubstring, "/public/img/attachments") + }) +} diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index 25d77557342..313f7892707 100644 --- a/pkg/components/renderer/renderer.go +++ b/pkg/components/renderer/renderer.go @@ -91,9 +91,15 @@ func RenderToPng(params *RenderOpts) (string, error) { timeout = 15 } + phantomDebugArg := "--debug=false" + if log.GetLogLevelFor("png-renderer") >= log.LvlDebug { + phantomDebugArg = "--debug=true" + } + cmdArgs := []string{ "--ignore-ssl-errors=true", "--web-security=false", + phantomDebugArg, scriptPath, "url=" + url, "width=" + params.Width, @@ -109,15 +115,13 @@ func RenderToPng(params *RenderOpts) (string, error) { } cmd := exec.Command(binPath, cmdArgs...) - stdout, err := cmd.StdoutPipe() + output, err := cmd.StdoutPipe() if err != nil { + rendererLog.Error("Could not acquire stdout pipe", err) return "", err } - stderr, err := cmd.StderrPipe() - if err != nil { - return "", err - } + cmd.Stderr = cmd.Stdout if params.Timezone != "" { baseEnviron := os.Environ() @@ -126,11 +130,12 @@ func RenderToPng(params *RenderOpts) (string, error) { err = cmd.Start() if err != nil { + rendererLog.Error("Could not start command", err) return "", err } - go io.Copy(os.Stdout, stdout) - go io.Copy(os.Stdout, stderr) + logWriter := log.NewLogWriter(rendererLog, log.LvlDebug, "[phantom] ") + go io.Copy(logWriter, output) done := make(chan error) go func() { diff --git a/pkg/log/log.go b/pkg/log/log.go index 88b90f0cf8e..0e6874e1b4b 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -21,6 +21,7 @@ import ( var Root log15.Logger var loggersToClose []DisposableHandler +var filters map[string]log15.Lvl func init() { loggersToClose = make([]DisposableHandler, 0) @@ -114,6 +115,25 @@ func Close() { loggersToClose = make([]DisposableHandler, 0) } +func GetLogLevelFor(name string) Lvl { + if level, ok := filters[name]; ok { + switch level { + case log15.LvlWarn: + return LvlWarn + case log15.LvlInfo: + return LvlInfo + case log15.LvlError: + return LvlError + case log15.LvlCrit: + return LvlCrit + default: + return LvlDebug + } + } + + return LvlInfo +} + var logLevels = map[string]log15.Lvl{ "trace": log15.LvlDebug, "debug": log15.LvlDebug, @@ -187,7 +207,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) { // Log level. _, level := getLogLevelFromConfig("log."+mode, defaultLevelName, cfg) - modeFilters := getFilters(util.SplitString(sec.Key("filters").String())) + filters := getFilters(util.SplitString(sec.Key("filters").String())) format := getLogFormat(sec.Key("format").MustString("")) var handler log15.Handler @@ -219,12 +239,12 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) { } for key, value := range defaultFilters { - if _, exist := modeFilters[key]; !exist { - modeFilters[key] = value + if _, exist := filters[key]; !exist { + filters[key] = value } } - handler = LogFilterHandler(level, modeFilters, handler) + handler = LogFilterHandler(level, filters, handler) handlers = append(handlers, handler) } diff --git a/pkg/log/log_writer.go b/pkg/log/log_writer.go new file mode 100644 index 00000000000..2ff401a7f0a --- /dev/null +++ b/pkg/log/log_writer.go @@ -0,0 +1,39 @@ +package log + +import ( + "io" + "strings" +) + +type logWriterImpl struct { + log Logger + level Lvl + prefix string +} + +func NewLogWriter(log Logger, level Lvl, prefix string) io.Writer { + return &logWriterImpl{ + log: log, + level: level, + prefix: prefix, + } +} + +func (l *logWriterImpl) Write(p []byte) (n int, err error) { + message := l.prefix + strings.TrimSpace(string(p)) + + switch l.level { + case LvlCrit: + l.log.Crit(message) + case LvlError: + l.log.Error(message) + case LvlWarn: + l.log.Warn(message) + case LvlInfo: + l.log.Info(message) + default: + l.log.Debug(message) + } + + return len(p), nil +} diff --git a/pkg/log/log_writer_test.go b/pkg/log/log_writer_test.go new file mode 100644 index 00000000000..4537b4d6100 --- /dev/null +++ b/pkg/log/log_writer_test.go @@ -0,0 +1,116 @@ +package log + +import ( + "testing" + + "github.com/inconshreveable/log15" + . "github.com/smartystreets/goconvey/convey" +) + +type FakeLogger struct { + debug string + info string + warn string + err string + crit string +} + +func (f *FakeLogger) New(ctx ...interface{}) log15.Logger { + return nil +} + +func (f *FakeLogger) Debug(msg string, ctx ...interface{}) { + f.debug = msg +} + +func (f *FakeLogger) Info(msg string, ctx ...interface{}) { + f.info = msg +} + +func (f *FakeLogger) Warn(msg string, ctx ...interface{}) { + f.warn = msg +} + +func (f *FakeLogger) Error(msg string, ctx ...interface{}) { + f.err = msg +} + +func (f *FakeLogger) Crit(msg string, ctx ...interface{}) { + f.crit = msg +} + +func (f *FakeLogger) GetHandler() log15.Handler { + return nil +} + +func (f *FakeLogger) SetHandler(l log15.Handler) {} + +func TestLogWriter(t *testing.T) { + Convey("When writing to a LogWriter", t, func() { + Convey("Should write using the correct level [crit]", func() { + fake := &FakeLogger{} + + crit := NewLogWriter(fake, LvlCrit, "") + n, err := crit.Write([]byte("crit")) + + So(n, ShouldEqual, 4) + So(err, ShouldBeNil) + So(fake.crit, ShouldEqual, "crit") + }) + + Convey("Should write using the correct level [error]", func() { + fake := &FakeLogger{} + + crit := NewLogWriter(fake, LvlError, "") + n, err := crit.Write([]byte("error")) + + So(n, ShouldEqual, 5) + So(err, ShouldBeNil) + So(fake.err, ShouldEqual, "error") + }) + + Convey("Should write using the correct level [warn]", func() { + fake := &FakeLogger{} + + crit := NewLogWriter(fake, LvlWarn, "") + n, err := crit.Write([]byte("warn")) + + So(n, ShouldEqual, 4) + So(err, ShouldBeNil) + So(fake.warn, ShouldEqual, "warn") + }) + + Convey("Should write using the correct level [info]", func() { + fake := &FakeLogger{} + + crit := NewLogWriter(fake, LvlInfo, "") + n, err := crit.Write([]byte("info")) + + So(n, ShouldEqual, 4) + So(err, ShouldBeNil) + So(fake.info, ShouldEqual, "info") + }) + + Convey("Should write using the correct level [debug]", func() { + fake := &FakeLogger{} + + crit := NewLogWriter(fake, LvlDebug, "") + n, err := crit.Write([]byte("debug")) + + So(n, ShouldEqual, 5) + So(err, ShouldBeNil) + So(fake.debug, ShouldEqual, "debug") + }) + + Convey("Should prefix the output with the prefix", func() { + fake := &FakeLogger{} + + crit := NewLogWriter(fake, LvlDebug, "prefix") + n, err := crit.Write([]byte("debug")) + + So(n, ShouldEqual, 5) // n is how much of input consumed + So(err, ShouldBeNil) + So(fake.debug, ShouldEqual, "prefixdebug") + }) + }) +} diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 45561783e43..5527c7271d6 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -3,21 +3,20 @@ package login import ( "errors" - "crypto/subtle" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) var ( - ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") ) type LoginUserQuery struct { - Username string - Password string - User *m.User + Username string + Password string + User *m.User + IpAddress string } func Init() { @@ -26,41 +25,31 @@ func Init() { } func AuthenticateUser(query *LoginUserQuery) error { - err := loginUsingGrafanaDB(query) - if err == nil || err != ErrInvalidCredentials { + if err := validateLoginAttempts(query.Username); err != nil { return err } - if setting.LdapEnabled { - for _, server := range LdapCfg.Servers { - author := NewLdapAuthenticator(server) - err = author.Login(query) - if err == nil || err != ErrInvalidCredentials { - return err - } + err := loginUsingGrafanaDB(query) + if err == nil || (err != m.ErrUserNotFound && err != ErrInvalidCredentials) { + return err + } + + ldapEnabled, ldapErr := loginUsingLdap(query) + if ldapEnabled { + if ldapErr == nil || ldapErr != ErrInvalidCredentials { + return ldapErr } + + err = ldapErr + } + + if err == ErrInvalidCredentials { + saveInvalidLoginAttempt(query) + } + + if err == m.ErrUserNotFound { + return ErrInvalidCredentials } return err } - -func loginUsingGrafanaDB(query *LoginUserQuery) error { - userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} - - if err := bus.Dispatch(&userQuery); err != nil { - if err == m.ErrUserNotFound { - return ErrInvalidCredentials - } - return err - } - - user := userQuery.Result - - passwordHashed := util.EncodePassword(query.Password, user.Salt) - if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(user.Password)) != 1 { - return ErrInvalidCredentials - } - - query.User = user - return nil -} diff --git a/pkg/login/auth_test.go b/pkg/login/auth_test.go new file mode 100644 index 00000000000..59d3c8f2b33 --- /dev/null +++ b/pkg/login/auth_test.go @@ -0,0 +1,214 @@ +package login + +import ( + "errors" + "testing" + + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestAuthenticateUser(t *testing.T) { + Convey("Authenticate user", t, func() { + authScenario("When a user authenticates having too many login attempts", func(sc *authScenarioContext) { + mockLoginAttemptValidation(ErrTooManyLoginAttempts, sc) + mockLoginUsingGrafanaDB(nil, sc) + mockLoginUsingLdap(true, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeFalse) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate with valid credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(nil, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, nil) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate and unexpected error occurs", func(sc *authScenarioContext) { + customErr := errors.New("custom") + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(customErr, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, customErr) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and ldap disabled", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(false, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and invalid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue) + }) + }) + + authScenario("When a non-existing grafana user authenticate and valid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldBeNil) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and ldap returns unexpected error", func(sc *authScenarioContext) { + customErr := errors.New("custom") + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, customErr, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, customErr) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate with invalid credentials and invalid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(ErrInvalidCredentials, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue) + }) + }) + }) +} + +type authScenarioContext struct { + loginUserQuery *LoginUserQuery + grafanaLoginWasCalled bool + ldapLoginWasCalled bool + loginAttemptValidationWasCalled bool + saveInvalidLoginAttemptWasCalled bool +} + +type authScenarioFunc func(sc *authScenarioContext) + +func mockLoginUsingGrafanaDB(err error, sc *authScenarioContext) { + loginUsingGrafanaDB = func(query *LoginUserQuery) error { + sc.grafanaLoginWasCalled = true + return err + } +} + +func mockLoginUsingLdap(enabled bool, err error, sc *authScenarioContext) { + loginUsingLdap = func(query *LoginUserQuery) (bool, error) { + sc.ldapLoginWasCalled = true + return enabled, err + } +} + +func mockLoginAttemptValidation(err error, sc *authScenarioContext) { + validateLoginAttempts = func(username string) error { + sc.loginAttemptValidationWasCalled = true + return err + } +} + +func mockSaveInvalidLoginAttempt(sc *authScenarioContext) { + saveInvalidLoginAttempt = func(query *LoginUserQuery) { + sc.saveInvalidLoginAttemptWasCalled = true + } +} + +func authScenario(desc string, fn authScenarioFunc) { + Convey(desc, func() { + origLoginUsingGrafanaDB := loginUsingGrafanaDB + origLoginUsingLdap := loginUsingLdap + origValidateLoginAttempts := validateLoginAttempts + origSaveInvalidLoginAttempt := saveInvalidLoginAttempt + + sc := &authScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + } + + defer func() { + loginUsingGrafanaDB = origLoginUsingGrafanaDB + loginUsingLdap = origLoginUsingLdap + validateLoginAttempts = origValidateLoginAttempts + saveInvalidLoginAttempt = origSaveInvalidLoginAttempt + }() + + fn(sc) + }) +} diff --git a/pkg/login/brute_force_login_protection.go b/pkg/login/brute_force_login_protection.go new file mode 100644 index 00000000000..2ea93979c7a --- /dev/null +++ b/pkg/login/brute_force_login_protection.go @@ -0,0 +1,48 @@ +package login + +import ( + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + maxInvalidLoginAttempts int64 = 5 + loginAttemptsWindow time.Duration = time.Minute * 5 +) + +var validateLoginAttempts = func(username string) error { + if setting.DisableBruteForceLoginProtection { + return nil + } + + loginAttemptCountQuery := m.GetUserLoginAttemptCountQuery{ + Username: username, + Since: time.Now().Add(-loginAttemptsWindow), + } + + if err := bus.Dispatch(&loginAttemptCountQuery); err != nil { + return err + } + + if loginAttemptCountQuery.Result >= maxInvalidLoginAttempts { + return ErrTooManyLoginAttempts + } + + return nil +} + +var saveInvalidLoginAttempt = func(query *LoginUserQuery) { + if setting.DisableBruteForceLoginProtection { + return + } + + loginAttemptCommand := m.CreateLoginAttemptCommand{ + Username: query.Username, + IpAddress: query.IpAddress, + } + + bus.Dispatch(&loginAttemptCommand) +} diff --git a/pkg/login/brute_force_login_protection_test.go b/pkg/login/brute_force_login_protection_test.go new file mode 100644 index 00000000000..5375134ba88 --- /dev/null +++ b/pkg/login/brute_force_login_protection_test.go @@ -0,0 +1,125 @@ +package login + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestLoginAttemptsValidation(t *testing.T) { + Convey("Validate login attempts", t, func() { + Convey("Given brute force login protection enabled", func() { + setting.DisableBruteForceLoginProtection = false + + Convey("When user login attempt count equals max-1 ", func() { + withLoginAttempts(maxInvalidLoginAttempts - 1) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count equals max ", func() { + withLoginAttempts(maxInvalidLoginAttempts) + err := validateLoginAttempts("user") + + Convey("it should result in too many login attempts error", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + }) + }) + + Convey("When user login attempt count is greater than max ", func() { + withLoginAttempts(maxInvalidLoginAttempts + 5) + err := validateLoginAttempts("user") + + Convey("it should result in too many login attempts error", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + }) + }) + + Convey("When saving invalid login attempt", func() { + defer bus.ClearBusHandlers() + createLoginAttemptCmd := &m.CreateLoginAttemptCommand{} + + bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error { + createLoginAttemptCmd = cmd + return nil + }) + + saveInvalidLoginAttempt(&LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }) + + Convey("it should dispatch command", func() { + So(createLoginAttemptCmd, ShouldNotBeNil) + So(createLoginAttemptCmd.Username, ShouldEqual, "user") + So(createLoginAttemptCmd.IpAddress, ShouldEqual, "192.168.1.1:56433") + }) + }) + }) + + Convey("Given brute force login protection disabled", func() { + setting.DisableBruteForceLoginProtection = true + + Convey("When user login attempt count equals max-1 ", func() { + withLoginAttempts(maxInvalidLoginAttempts - 1) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count equals max ", func() { + withLoginAttempts(maxInvalidLoginAttempts) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count is greater than max ", func() { + withLoginAttempts(maxInvalidLoginAttempts + 5) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When saving invalid login attempt", func() { + defer bus.ClearBusHandlers() + createLoginAttemptCmd := (*m.CreateLoginAttemptCommand)(nil) + + bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error { + createLoginAttemptCmd = cmd + return nil + }) + + saveInvalidLoginAttempt(&LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }) + + Convey("it should not dispatch command", func() { + So(createLoginAttemptCmd, ShouldBeNil) + }) + }) + }) + }) +} + +func withLoginAttempts(loginAttempts int64) { + bus.AddHandler("test", func(query *m.GetUserLoginAttemptCountQuery) error { + query.Result = loginAttempts + return nil + }) +} diff --git a/pkg/login/grafana_login.go b/pkg/login/grafana_login.go new file mode 100644 index 00000000000..677ba776e4f --- /dev/null +++ b/pkg/login/grafana_login.go @@ -0,0 +1,35 @@ +package login + +import ( + "crypto/subtle" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +var validatePassword = func(providedPassword string, userPassword string, userSalt string) error { + passwordHashed := util.EncodePassword(providedPassword, userSalt) + if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(userPassword)) != 1 { + return ErrInvalidCredentials + } + + return nil +} + +var loginUsingGrafanaDB = func(query *LoginUserQuery) error { + userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} + + if err := bus.Dispatch(&userQuery); err != nil { + return err + } + + user := userQuery.Result + + if err := validatePassword(query.Password, user.Password, user.Salt); err != nil { + return err + } + + query.User = user + return nil +} diff --git a/pkg/login/grafana_login_test.go b/pkg/login/grafana_login_test.go new file mode 100644 index 00000000000..88e52224113 --- /dev/null +++ b/pkg/login/grafana_login_test.go @@ -0,0 +1,139 @@ +package login + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGrafanaLogin(t *testing.T) { + Convey("Login using Grafana DB", t, func() { + grafanaLoginScenario("When login with non-existing user", func(sc *grafanaLoginScenarioContext) { + sc.withNonExistingUser() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should result in user not found error", func() { + So(err, ShouldEqual, m.ErrUserNotFound) + }) + + Convey("it should not call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeFalse) + }) + + Convey("it should not pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldBeNil) + }) + }) + + grafanaLoginScenario("When login with invalid credentials", func(sc *grafanaLoginScenarioContext) { + sc.withInvalidPassword() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should result in invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeTrue) + }) + + Convey("it should not pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldBeNil) + }) + }) + + grafanaLoginScenario("When login with valid credentials", func(sc *grafanaLoginScenarioContext) { + sc.withValidCredentials() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeTrue) + }) + + Convey("it should pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldNotBeNil) + So(sc.loginUserQuery.User.Login, ShouldEqual, sc.loginUserQuery.Username) + So(sc.loginUserQuery.User.Password, ShouldEqual, sc.loginUserQuery.Password) + }) + }) + }) +} + +type grafanaLoginScenarioContext struct { + loginUserQuery *LoginUserQuery + validatePasswordCalled bool +} + +type grafanaLoginScenarioFunc func(c *grafanaLoginScenarioContext) + +func grafanaLoginScenario(desc string, fn grafanaLoginScenarioFunc) { + Convey(desc, func() { + origValidatePassword := validatePassword + + sc := &grafanaLoginScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + validatePasswordCalled: false, + } + + defer func() { + validatePassword = origValidatePassword + }() + + fn(sc) + }) +} + +func mockPasswordValidation(valid bool, sc *grafanaLoginScenarioContext) { + validatePassword = func(providedPassword string, userPassword string, userSalt string) error { + sc.validatePasswordCalled = true + + if !valid { + return ErrInvalidCredentials + } + + return nil + } +} + +func (sc *grafanaLoginScenarioContext) getUserByLoginQueryReturns(user *m.User) { + bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error { + if user == nil { + return m.ErrUserNotFound + } + + query.Result = user + return nil + }) +} + +func (sc *grafanaLoginScenarioContext) withValidCredentials() { + sc.getUserByLoginQueryReturns(&m.User{ + Id: 1, + Login: sc.loginUserQuery.Username, + Password: sc.loginUserQuery.Password, + Salt: "salt", + }) + mockPasswordValidation(true, sc) +} + +func (sc *grafanaLoginScenarioContext) withNonExistingUser() { + sc.getUserByLoginQueryReturns(nil) +} + +func (sc *grafanaLoginScenarioContext) withInvalidPassword() { + sc.getUserByLoginQueryReturns(&m.User{ + Password: sc.loginUserQuery.Password, + Salt: "salt", + }) + mockPasswordValidation(false, sc) +} diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go new file mode 100644 index 00000000000..b74b69db036 --- /dev/null +++ b/pkg/login/ldap_login.go @@ -0,0 +1,21 @@ +package login + +import ( + "github.com/grafana/grafana/pkg/setting" +) + +var loginUsingLdap = func(query *LoginUserQuery) (bool, error) { + if !setting.LdapEnabled { + return false, nil + } + + for _, server := range LdapCfg.Servers { + author := NewLdapAuthenticator(server) + err := author.Login(query) + if err == nil || err != ErrInvalidCredentials { + return true, err + } + } + + return true, ErrInvalidCredentials +} diff --git a/pkg/login/ldap_login_test.go b/pkg/login/ldap_login_test.go new file mode 100644 index 00000000000..6af125566e8 --- /dev/null +++ b/pkg/login/ldap_login_test.go @@ -0,0 +1,172 @@ +package login + +import ( + "testing" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestLdapLogin(t *testing.T) { + Convey("Login using ldap", t, func() { + Convey("Given ldap enabled and a server configured", func() { + setting.LdapEnabled = true + LdapCfg.Servers = append(LdapCfg.Servers, + &LdapServerConf{ + Host: "", + }) + + ldapLoginScenario("When login with invalid credentials", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(false) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should return invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) + }) + }) + + ldapLoginScenario("When login with valid credentials", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(true) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should not return error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) + }) + }) + }) + + Convey("Given ldap enabled and no server configured", func() { + setting.LdapEnabled = true + LdapCfg.Servers = make([]*LdapServerConf, 0) + + ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(true) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should return invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should not call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse) + }) + }) + }) + + Convey("Given ldap disabled", func() { + setting.LdapEnabled = false + + ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(false) + enabled, err := loginUsingLdap(&LoginUserQuery{ + Username: "user", + Password: "pwd", + }) + + Convey("it should return false", func() { + So(enabled, ShouldBeFalse) + }) + + Convey("it should not return error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should not call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse) + }) + }) + }) + }) +} + +func mockLdapAuthenticator(valid bool) *mockLdapAuther { + mock := &mockLdapAuther{ + validLogin: valid, + } + + NewLdapAuthenticator = func(server *LdapServerConf) ILdapAuther { + return mock + } + + return mock +} + +type mockLdapAuther struct { + validLogin bool + loginCalled bool +} + +func (a *mockLdapAuther) Login(query *LoginUserQuery) error { + a.loginCalled = true + + if !a.validLogin { + return ErrInvalidCredentials + } + + return nil +} + +func (a *mockLdapAuther) SyncSignedInUser(signedInUser *m.SignedInUser) error { + return nil +} + +func (a *mockLdapAuther) GetGrafanaUserFor(ldapUser *LdapUserInfo) (*m.User, error) { + return nil, nil +} + +func (a *mockLdapAuther) SyncOrgRoles(user *m.User, ldapUser *LdapUserInfo) error { + return nil +} + +type ldapLoginScenarioContext struct { + loginUserQuery *LoginUserQuery + ldapAuthenticatorMock *mockLdapAuther +} + +type ldapLoginScenarioFunc func(c *ldapLoginScenarioContext) + +func ldapLoginScenario(desc string, fn ldapLoginScenarioFunc) { + Convey(desc, func() { + origNewLdapAuthenticator := NewLdapAuthenticator + + sc := &ldapLoginScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + ldapAuthenticatorMock: &mockLdapAuther{}, + } + + defer func() { + NewLdapAuthenticator = origNewLdapAuthenticator + }() + + fn(sc) + }) +} + +func (sc *ldapLoginScenarioContext) withLoginResult(valid bool) { + sc.ldapAuthenticatorMock = mockLdapAuthenticator(valid) +} diff --git a/pkg/login/settings.go b/pkg/login/ldap_settings.go similarity index 100% rename from pkg/login/settings.go rename to pkg/login/ldap_settings.go diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 4d7de98f2ea..4d4a11d0faa 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -379,6 +379,7 @@ func sendUsageStats() { metrics["stats.alerts.count"] = statsQuery.Result.Alerts metrics["stats.active_users.count"] = statsQuery.Result.ActiveUsers metrics["stats.datasources.count"] = statsQuery.Result.Datasources + metrics["stats.stars.count"] = statsQuery.Result.Stars dsStats := models.GetDataSourceStatsQuery{} if err := bus.Dispatch(&dsStats); err != nil { diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index be3415d990b..826287e12f3 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -42,8 +42,7 @@ func accessForbidden(c *Context) { return } - c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/") - c.Redirect(setting.AppSubUrl + "/login") + c.Redirect(setting.AppSubUrl + "/") } func notAuthorized(c *Context) { diff --git a/pkg/middleware/dashboard_redirect.go b/pkg/middleware/dashboard_redirect.go new file mode 100644 index 00000000000..4a3812fb8a2 --- /dev/null +++ b/pkg/middleware/dashboard_redirect.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "fmt" + "strings" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "gopkg.in/macaron.v1" +) + +func getDashboardUrlBySlug(orgId int64, slug string) (string, error) { + query := m.GetDashboardQuery{Slug: slug, OrgId: orgId} + + if err := bus.Dispatch(&query); err != nil { + return "", m.ErrDashboardNotFound + } + + return m.GetDashboardUrl(query.Result.Uid, query.Result.Slug), nil +} + +func RedirectFromLegacyDashboardUrl() macaron.Handler { + return func(c *Context) { + slug := c.Params("slug") + + if slug != "" { + if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil { + url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery) + c.Redirect(url, 301) + return + } + } + } +} + +func RedirectFromLegacyDashboardSoloUrl() macaron.Handler { + return func(c *Context) { + slug := c.Params("slug") + + if slug != "" { + if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil { + url = strings.Replace(url, "/d/", "/d-solo/", 1) + url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery) + c.Redirect(url, 301) + return + } + } + } +} diff --git a/pkg/middleware/dashboard_redirect_test.go b/pkg/middleware/dashboard_redirect_test.go new file mode 100644 index 00000000000..0af06347ed0 --- /dev/null +++ b/pkg/middleware/dashboard_redirect_test.go @@ -0,0 +1,58 @@ +package middleware + +import ( + "strings" + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" + . "github.com/smartystreets/goconvey/convey" +) + +func TestMiddlewareDashboardRedirect(t *testing.T) { + Convey("Given the dashboard redirect middleware", t, func() { + bus.ClearBusHandlers() + redirectFromLegacyDashboardUrl := RedirectFromLegacyDashboardUrl() + redirectFromLegacyDashboardSoloUrl := RedirectFromLegacyDashboardSoloUrl() + + fakeDash := m.NewDashboard("Child dash") + fakeDash.Id = 1 + fakeDash.FolderId = 1 + fakeDash.HasAcl = false + fakeDash.Uid = util.GenerateShortUid() + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeDash + return nil + }) + + middlewareScenario("GET dashboard by legacy url", func(sc *scenarioContext) { + sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler) + + sc.fakeReqWithParams("GET", "/dashboard/db/dash?orgId=1&panelId=2", map[string]string{}).exec() + + Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() { + So(sc.resp.Code, ShouldEqual, 301) + redirectUrl, _ := sc.resp.Result().Location() + So(redirectUrl.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)) + So(len(redirectUrl.Query()), ShouldEqual, 2) + }) + }) + + middlewareScenario("GET dashboard solo by legacy url", func(sc *scenarioContext) { + sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler) + + sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash?orgId=1&panelId=2", map[string]string{}).exec() + + Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() { + So(sc.resp.Code, ShouldEqual, 301) + redirectUrl, _ := sc.resp.Result().Location() + expectedUrl := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug) + expectedUrl = strings.Replace(expectedUrl, "/d/", "/d-solo/", 1) + So(redirectUrl.Path, ShouldEqual, expectedUrl) + So(len(redirectUrl.Query()), ShouldEqual, 2) + }) + }) + }) +} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 259d800f0a9..f0c952811cd 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -206,7 +206,9 @@ func (ctx *Context) Handle(status int, title string, err error) { ctx.Data["Title"] = title ctx.Data["AppSubUrl"] = setting.AppSubUrl - ctx.HTML(status, strconv.Itoa(status)) + ctx.Data["Theme"] = "dark" + + ctx.HTML(status, "error") } func (ctx *Context) JsonOK(message string) { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 0d9e0e5b973..ffd8e8a0af0 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -399,6 +399,20 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { return sc } +func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + q := req.URL.Query() + for k, v := range queryParams { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + So(err, ShouldBeNil) + sc.req = req + + return sc +} + func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext { sc.handlerFunc = fn return sc diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index 0c9dc4670e2..388acc15afc 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -137,7 +137,7 @@ func Recovery() macaron.Handler { c.JSON(500, resp) } else { - c.HTML(500, "500") + c.HTML(500, "error") } } }() diff --git a/pkg/models/alert.go b/pkg/models/alert.go index fa3f4b466a8..b378c5cf90f 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -159,10 +159,6 @@ type SetAlertStateCommand struct { Timestamp time.Time } -type DeleteAlertCommand struct { - AlertId int64 -} - //Queries type GetAlertsQuery struct { OrgId int64 diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index fa7ad00de7f..933487650e3 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/models/dashboard_acl.go @@ -59,6 +59,11 @@ type DashboardAclInfoDTO struct { Role *RoleType `json:"role,omitempty"` Permission PermissionType `json:"permission"` PermissionName string `json:"permissionName"` + Uid string `json:"uid"` + Title string `json:"title"` + Slug string `json:"slug"` + IsFolder bool `json:"isFolder"` + Url string `json:"url"` } // diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 9e024d2db5e..adb310bd3f9 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -2,23 +2,28 @@ package models import ( "errors" + "fmt" "strings" "time" "github.com/gosimple/slug" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/setting" ) // Typed errors var ( - ErrDashboardNotFound = errors.New("Dashboard not found") - ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") - ErrDashboardWithSameNameExists = errors.New("A dashboard with the same name already exists") - ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") - ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") - ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") - ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard") - ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") + ErrDashboardNotFound = errors.New("Dashboard not found") + ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") + ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") + ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") + ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") + ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") + ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") + ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard") + ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") + ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") + ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") ) type UpdatePluginDashboardError struct { @@ -39,6 +44,7 @@ var ( // Dashboard model type Dashboard struct { Id int64 + Uid string Slug string OrgId int64 GnetId int64 @@ -112,6 +118,10 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard { dash.GnetId = int64(gnetId) } + if uid, err := dash.Data.Get("uid").String(); err == nil { + dash.Uid = uid + } + return dash } @@ -152,6 +162,40 @@ func SlugifyTitle(title string) string { return slug.Make(strings.ToLower(title)) } +// GetUrl return the html url for a folder if it's folder, otherwise for a dashboard +func (dash *Dashboard) GetUrl() string { + return GetDashboardFolderUrl(dash.IsFolder, dash.Uid, dash.Slug) +} + +// Return the html url for a dashboard +func (dash *Dashboard) GenerateUrl() string { + return GetDashboardUrl(dash.Uid, dash.Slug) +} + +// GetDashboardFolderUrl return the html url for a folder if it's folder, otherwise for a dashboard +func GetDashboardFolderUrl(isFolder bool, uid string, slug string) string { + if isFolder { + return GetFolderUrl(uid, slug) + } + + return GetDashboardUrl(uid, slug) +} + +// Return the html url for a dashboard +func GetDashboardUrl(uid string, slug string) string { + return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug) +} + +// Return the full url for a dashboard +func GetFullDashboardUrl(uid string, slug string) string { + return fmt.Sprintf("%s%s", setting.AppUrl, GetDashboardUrl(uid, slug)) +} + +// GetFolderUrl return the html url for a folder +func GetFolderUrl(folderUid string, slug string) string { + return fmt.Sprintf("%s/dashboards/f/%s/%s", setting.AppSubUrl, folderUid, slug) +} + // // COMMANDS // @@ -197,8 +241,9 @@ type DeleteDashboardCommand struct { // type GetDashboardQuery struct { - Slug string // required if no Id is specified + Slug string // required if no Id or Uid is specified Id int64 // optional if slug is set + Uid string // optional if slug is set OrgId int64 Result *Dashboard @@ -219,6 +264,14 @@ type GetDashboardsQuery struct { Result []*Dashboard } +type GetDashboardPermissionsForUserQuery struct { + DashboardIds []int64 + OrgId int64 + UserId int64 + OrgRole RoleType + Result []*DashboardPermissionForUser +} + type GetDashboardsByPluginIdQuery struct { OrgId int64 PluginId string @@ -235,3 +288,38 @@ type GetProvisionedDashboardDataQuery struct { Result []*DashboardProvisioning } + +type GetDashboardsBySlugQuery struct { + OrgId int64 + Slug string + + Result []*Dashboard +} + +type GetFoldersForSignedInUserQuery struct { + OrgId int64 + SignedInUser *SignedInUser + Title string + Result []*DashboardFolder +} + +type DashboardFolder struct { + Id int64 `json:"id"` + Title string `json:"title"` +} + +type DashboardPermissionForUser struct { + DashboardId int64 `json:"dashboardId"` + Permission PermissionType `json:"permission"` + PermissionName string `json:"permissionName"` +} + +type DashboardRef struct { + Uid string + Slug string +} + +type GetDashboardRefByIdQuery struct { + Id int64 + Result *DashboardRef +} diff --git a/pkg/models/login_attempt.go b/pkg/models/login_attempt.go new file mode 100644 index 00000000000..e4391927702 --- /dev/null +++ b/pkg/models/login_attempt.go @@ -0,0 +1,36 @@ +package models + +import ( + "time" +) + +type LoginAttempt struct { + Id int64 + Username string + IpAddress string + Created time.Time +} + +// --------------------- +// COMMANDS + +type CreateLoginAttemptCommand struct { + Username string + IpAddress string + + Result LoginAttempt +} + +type DeleteOldLoginAttemptsCommand struct { + OlderThan time.Time + DeletedRows int64 +} + +// --------------------- +// QUERIES + +type GetUserLoginAttemptCountQuery struct { + Username string + Since time.Time + Result int64 +} diff --git a/pkg/models/stats.go b/pkg/models/stats.go index 0d982c3f4bd..e132d88c030 100644 --- a/pkg/models/stats.go +++ b/pkg/models/stats.go @@ -8,6 +8,7 @@ type SystemStats struct { Orgs int64 Playlists int64 Alerts int64 + Stars int64 } type DataSourceStats struct { diff --git a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go index 0afa2a400e4..170e187b282 100644 --- a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go @@ -2,6 +2,7 @@ package wrapper import ( "context" + "errors" "fmt" "github.com/grafana/grafana/pkg/components/null" @@ -67,9 +68,15 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou } for _, r := range pbres.Results { - res.Results[r.RefId] = &tsdb.QueryResult{ + qr := &tsdb.QueryResult{ RefId: r.RefId, Series: []*tsdb.TimeSeries{}, + Tables: []*tsdb.Table{}, + } + + if r.Error != "" { + qr.Error = errors.New(r.Error) + qr.ErrorString = r.Error } for _, s := range r.GetSeries() { @@ -80,7 +87,7 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou points = append(points, po) } - res.Results[r.RefId].Series = append(res.Results[r.RefId].Series, &tsdb.TimeSeries{ + qr.Series = append(qr.Series, &tsdb.TimeSeries{ Name: s.Name, Tags: s.Tags, Points: points, @@ -91,7 +98,9 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou if err != nil { return nil, err } - res.Results[r.RefId].Tables = mappedTables + qr.Tables = mappedTables + + res.Results[r.RefId] = qr } return res, nil @@ -116,6 +125,7 @@ func (tw *DatasourcePluginWrapper) mapTable(t *datasource.Table) (*tsdb.Table, e }) } + table.Rows = make([]tsdb.RowValues, 0) for _, r := range t.GetRows() { row := tsdb.RowValues{} for _, rv := range r.Values { diff --git a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go index c85cb6cea52..834e8238e3a 100644 --- a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go @@ -75,7 +75,7 @@ func TestMappingRowValue(t *testing.T) { boolRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_BOOL, BoolValue: true}) haveBool, ok := boolRowValue.(bool) if !ok || haveBool != true { - t.Fatalf("Expected true, was %s", haveBool) + t.Fatalf("Expected true, was %v", haveBool) } intRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_INT64, Int64Value: 42}) diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index f5663deb8ca..d598203d675 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -12,17 +12,19 @@ import ( ) type EvalContext struct { - Firing bool - IsTestRun bool - EvalMatches []*EvalMatch - Logs []*ResultLogEntry - Error error - ConditionEvals string - StartTime time.Time - EndTime time.Time - Rule *Rule - log log.Logger - dashboardSlug string + Firing bool + IsTestRun bool + EvalMatches []*EvalMatch + Logs []*ResultLogEntry + Error error + ConditionEvals string + StartTime time.Time + EndTime time.Time + Rule *Rule + log log.Logger + + dashboardRef *m.DashboardRef + ImagePublicUrl string ImageOnDiskPath string NoDataFound bool @@ -83,29 +85,30 @@ func (c *EvalContext) GetNotificationTitle() string { return "[" + c.GetStateModel().Text + "] " + c.Rule.Name } -func (c *EvalContext) GetDashboardSlug() (string, error) { - if c.dashboardSlug != "" { - return c.dashboardSlug, nil +func (c *EvalContext) GetDashboardUID() (*m.DashboardRef, error) { + if c.dashboardRef != nil { + return c.dashboardRef, nil } - slugQuery := &m.GetDashboardSlugByIdQuery{Id: c.Rule.DashboardId} - if err := bus.Dispatch(slugQuery); err != nil { - return "", err + uidQuery := &m.GetDashboardRefByIdQuery{Id: c.Rule.DashboardId} + if err := bus.Dispatch(uidQuery); err != nil { + return nil, err } - c.dashboardSlug = slugQuery.Result - return c.dashboardSlug, nil + c.dashboardRef = uidQuery.Result + return c.dashboardRef, nil } +const urlFormat = "%s?fullscreen=true&edit=true&tab=alert&panelId=%d&orgId=%d" + func (c *EvalContext) GetRuleUrl() (string, error) { if c.IsTestRun { return setting.AppUrl, nil } - if slug, err := c.GetDashboardSlug(); err != nil { + if ref, err := c.GetDashboardUID(); err != nil { return "", err } else { - ruleUrl := fmt.Sprintf("%sdashboard/db/%s?fullscreen&edit&tab=alert&panelId=%d&orgId=%d", setting.AppUrl, slug, c.Rule.PanelId, c.Rule.OrgId) - return ruleUrl, nil + return fmt.Sprintf(urlFormat, m.GetFullDashboardUrl(ref.Uid, ref.Slug), c.Rule.PanelId, c.Rule.OrgId), nil } } diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 47c9e0c590e..af9ba52a52a 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -87,10 +87,10 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) { IsAlertContext: true, } - if slug, err := context.GetDashboardSlug(); err != nil { + if ref, err := context.GetDashboardUID(); err != nil { return err } else { - renderOpts.Path = fmt.Sprintf("dashboard-solo/db/%s?&panelId=%d", slug, context.Rule.PanelId) + renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?panelId=%d", ref.Uid, ref.Slug, context.Rule.PanelId) } if imagePath, err := renderer.RenderToPng(renderOpts); err != nil { diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index 1a812f49ca3..82be9d1df4e 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -23,6 +23,10 @@ func init() { API Key +
+ Alert API Url + +
= permission { @@ -102,7 +106,41 @@ func (g *DashboardGuardian) HasPermission(permission m.PermissionType) (bool, er return false, nil } -// Returns dashboard acl +func (g *DashboardGuardian) CheckPermissionBeforeRemove(permission m.PermissionType, aclIdToRemove int64) (bool, error) { + if g.user.OrgRole == m.ROLE_ADMIN { + return true, nil + } + + acl, err := g.GetAcl() + if err != nil { + return false, err + } + + for i, p := range acl { + if p.Id == aclIdToRemove { + acl = append(acl[:i], acl[i+1:]...) + break + } + } + + return g.checkAcl(permission, acl) +} + +func (g *DashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { + if g.user.OrgRole == m.ROLE_ADMIN { + return true, nil + } + + acl := []*m.DashboardAclInfoDTO{} + + for _, p := range updatePermissions { + acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission}) + } + + return g.checkAcl(permission, acl) +} + +// GetAcl returns dashboard acl func (g *DashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { if g.acl != nil { return g.acl, nil diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index a602ca71df3..ab9e85f4d38 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -5,20 +5,25 @@ import ( "path/filepath" "strings" + "github.com/grafana/grafana/pkg/log" yaml "gopkg.in/yaml.v2" ) type configReader struct { path string + log log.Logger } func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { + var dashboards []*DashboardsAsConfig + files, err := ioutil.ReadDir(cr.path) + if err != nil { - return nil, err + cr.log.Error("cant read dashboard provisioning files from directory", "path", cr.path) + return dashboards, nil } - var dashboards []*DashboardsAsConfig for _, file := range files { if !strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml") { continue @@ -30,13 +35,13 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { return nil, err } - var datasource []*DashboardsAsConfig - err = yaml.Unmarshal(yamlFile, &datasource) + var dashCfg []*DashboardsAsConfig + err = yaml.Unmarshal(yamlFile, &dashCfg) if err != nil { return nil, err } - dashboards = append(dashboards, datasource...) + dashboards = append(dashboards, dashCfg...) } for i := range dashboards { diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index faca34438d8..bb960a72094 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -3,6 +3,7 @@ package dashboards import ( "testing" + "github.com/grafana/grafana/pkg/log" . "github.com/smartystreets/goconvey/convey" ) @@ -16,8 +17,8 @@ func TestDashboardsAsConfig(t *testing.T) { Convey("Can read config file", func() { - cfgProvifer := configReader{path: simpleDashboardConfig} - cfg, err := cfgProvifer.readConfig() + cfgProvider := configReader{path: simpleDashboardConfig, log: log.New("test-logger")} + cfg, err := cfgProvider.readConfig() if err != nil { t.Fatalf("readConfig return an error %v", err) } @@ -47,16 +48,26 @@ func TestDashboardsAsConfig(t *testing.T) { So(ds2.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards") }) - Convey("Should skip broken config files", func() { + Convey("Should skip invalid path", func() { - cfgProvifer := configReader{path: brokenConfigs} - cfg, err := cfgProvifer.readConfig() + cfgProvider := configReader{path: "/invalid-directory", log: log.New("test-logger")} + cfg, err := cfgProvider.readConfig() if err != nil { t.Fatalf("readConfig return an error %v", err) } So(len(cfg), ShouldEqual, 0) + }) + Convey("Should skip broken config files", func() { + + cfgProvider := configReader{path: brokenConfigs, log: log.New("test-logger")} + cfg, err := cfgProvider.readConfig() + if err != nil { + t.Fatalf("readConfig return an error %v", err) + } + + So(len(cfg), ShouldEqual, 0) }) }) } diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index 1ee0f78497d..a5349517bbe 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -14,9 +14,10 @@ type DashboardProvisioner struct { } func Provision(ctx context.Context, configDirectory string) (*DashboardProvisioner, error) { + log := log.New("provisioning.dashboard") d := &DashboardProvisioner{ - cfgReader: &configReader{path: configDirectory}, - log: log.New("provisioning.dashboard"), + cfgReader: &configReader{path: configDirectory, log: log}, + log: log, ctx: ctx, } diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index ce631c565d4..aa1308ffa29 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -25,13 +25,13 @@ func Provision(configDirectory string) error { type DatasourceProvisioner struct { log log.Logger - cfgProvider configReader + cfgProvider *configReader } func newDatasourceProvisioner(log log.Logger) DatasourceProvisioner { return DatasourceProvisioner{ log: log, - cfgProvider: configReader{}, + cfgProvider: &configReader{log: log}, } } @@ -95,15 +95,19 @@ func (dc *DatasourceProvisioner) deleteDatasources(dsToDelete []*DeleteDatasourc return nil } -type configReader struct{} +type configReader struct { + log log.Logger +} + +func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) { + var datasources []*DatasourcesAsConfig -func (configReader) readConfig(path string) ([]*DatasourcesAsConfig, error) { files, err := ioutil.ReadDir(path) if err != nil { - return nil, err + cr.log.Error("cant read datasource provisioning files from directory", "path", path) + return datasources, nil } - var datasources []*DatasourcesAsConfig for _, file := range files { if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { filename, _ := filepath.Abs(filepath.Join(path, file.Name())) diff --git a/pkg/services/provisioning/datasources/datasources_test.go b/pkg/services/provisioning/datasources/datasources_test.go index f3252c28d9d..00dc59f6617 100644 --- a/pkg/services/provisioning/datasources/datasources_test.go +++ b/pkg/services/provisioning/datasources/datasources_test.go @@ -11,7 +11,7 @@ import ( ) var ( - logger log.Logger = log.New("fake.logger") + logger log.Logger = log.New("fake.log") oneDatasourcesConfig string = "" twoDatasourcesConfig string = "./test-configs/two-datasources" twoDatasourcesConfigPurgeOthers string = "./test-configs/insert-two-delete-two" @@ -115,12 +115,23 @@ func TestDatasourceAsConfig(t *testing.T) { }) Convey("broken yaml should return error", func() { - _, err := configReader{}.readConfig(brokenYaml) + reader := &configReader{} + _, err := reader.readConfig(brokenYaml) So(err, ShouldNotBeNil) }) + Convey("skip invalid directory", func() { + cfgProvifer := &configReader{log: log.New("test logger")} + cfg, err := cfgProvifer.readConfig("./invalid-directory") + if err != nil { + t.Fatalf("readConfig return an error %v", err) + } + + So(len(cfg), ShouldEqual, 0) + }) + Convey("can read all properties", func() { - cfgProvifer := configReader{} + cfgProvifer := &configReader{log: log.New("test logger")} cfg, err := cfgProvifer.readConfig(allProperties) if err != nil { t.Fatalf("readConfig return an error %v", err) diff --git a/pkg/services/search/models.go b/pkg/services/search/models.go index cf510ed8462..6dea975d9fe 100644 --- a/pkg/services/search/models.go +++ b/pkg/services/search/models.go @@ -13,15 +13,17 @@ const ( type Hit struct { Id int64 `json:"id"` + Uid string `json:"uid"` Title string `json:"title"` Uri string `json:"uri"` - Slug string `json:"slug"` + Url string `json:"url"` Type HitType `json:"type"` Tags []string `json:"tags"` IsStarred bool `json:"isStarred"` FolderId int64 `json:"folderId,omitempty"` + FolderUid string `json:"folderUid,omitempty"` FolderTitle string `json:"folderTitle,omitempty"` - FolderSlug string `json:"folderSlug,omitempty"` + FolderUrl string `json:"folderUrl,omitempty"` } type HitList []*Hit diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index a34f56066e5..96af8bc49ee 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -14,7 +14,6 @@ func init() { bus.AddHandler("sql", SaveAlerts) bus.AddHandler("sql", HandleAlertsQuery) bus.AddHandler("sql", GetAlertById) - bus.AddHandler("sql", DeleteAlertById) bus.AddHandler("sql", GetAllAlertQueryHandler) bus.AddHandler("sql", SetAlertState) bus.AddHandler("sql", GetAlertStatesForDashboard) @@ -24,7 +23,7 @@ func init() { func GetAlertById(query *m.GetAlertByIdQuery) error { alert := m.Alert{} - has, err := x.Id(query.Id).Get(&alert) + has, err := x.ID(query.Id).Get(&alert) if !has { return fmt.Errorf("could not find alert") } @@ -61,12 +60,6 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro return nil } -func DeleteAlertById(cmd *m.DeleteAlertCommand) error { - return inTransaction(func(sess *DBSession) error { - return deleteAlertByIdInternal(cmd.AlertId, "DeleteAlertCommand", sess) - }) -} - func HandleAlertsQuery(query *m.GetAlertsQuery) error { var sql bytes.Buffer params := make([]interface{}, 0) @@ -113,7 +106,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error { } alerts := make([]*m.Alert, 0) - if err := x.Sql(sql.String(), params...).Find(&alerts); err != nil { + if err := x.SQL(sql.String(), params...).Find(&alerts); err != nil { return err } @@ -260,7 +253,7 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error { alert.ExecutionError = cmd.Error } - sess.Id(alert.Id).Update(&alert) + sess.ID(alert.Id).Update(&alert) return nil }) } @@ -324,7 +317,7 @@ func GetAlertStatesForDashboard(query *m.GetAlertStatesForDashboardQuery) error WHERE org_id = ? AND dashboard_id = ?` query.Result = make([]*m.AlertStateInfoDTO, 0) - err := x.Sql(rawSql, query.OrgId, query.DashboardId).Find(&query.Result) + err := x.SQL(rawSql, query.OrgId, query.DashboardId).Find(&query.Result) return err } diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index 5e66627f194..ae691c7166c 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -76,7 +76,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro sql.WriteString(`)`) results := make([]*m.AlertNotification, 0) - if err := x.Sql(sql.String(), params...).Find(&results); err != nil { + if err := x.SQL(sql.String(), params...).Find(&results); err != nil { return err } @@ -165,7 +165,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { return inTransaction(func(sess *DBSession) (err error) { current := m.AlertNotification{} - if _, err = sess.Id(cmd.Id).Get(¤t); err != nil { + if _, err = sess.ID(cmd.Id).Get(¤t); err != nil { return err } @@ -187,7 +187,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { sess.UseBool("is_default") - if affected, err := sess.Id(cmd.Id).Update(current); err != nil { + if affected, err := sess.ID(cmd.Id).Update(current); err != nil { return err } else if affected == 0 { return fmt.Errorf("Could not find alert notification") diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index effffb8bab4..76f1819a18c 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -138,6 +138,17 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I sql.WriteString(`WHERE annotation.org_id = ?`) params = append(params, query.OrgId) + if query.AnnotationId != 0 { + fmt.Print("annotation query") + sql.WriteString(` AND annotation.id = ?`) + params = append(params, query.AnnotationId) + } + + if query.RegionId != 0 { + sql.WriteString(` AND annotation.region_id = ?`) + params = append(params, query.RegionId) + } + if query.AlertId != 0 { sql.WriteString(` AND annotation.alert_id = ?`) params = append(params, query.AlertId) @@ -197,6 +208,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit)) items := make([]*annotations.ItemDTO, 0) + if err := x.Sql(sql.String(), params...).Find(&items); err != nil { return nil, err } diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go index 2afd4479b66..d5cee110b9a 100644 --- a/pkg/services/sqlstore/annotation_test.go +++ b/pkg/services/sqlstore/annotation_test.go @@ -51,6 +51,20 @@ func TestAnnotations(t *testing.T) { So(err, ShouldBeNil) So(annotation.Id, ShouldBeGreaterThan, 0) + annotation2 := &annotations.Item{ + OrgId: 1, + UserId: 1, + DashboardId: 2, + Text: "hello", + Type: "alert", + Epoch: 20, + Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + RegionId: 1, + } + err = repo.Save(annotation2) + So(err, ShouldBeNil) + So(annotation2.Id, ShouldBeGreaterThan, 0) + Convey("Can query for annotation", func() { items, err := repo.Find(&annotations.ItemQuery{ OrgId: 1, @@ -67,6 +81,28 @@ func TestAnnotations(t *testing.T) { }) }) + Convey("Can query for annotation by id", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + AnnotationId: annotation2.Id, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + So(items[0].Id, ShouldEqual, annotation2.Id) + }) + + Convey("Can query for annotation by region id", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + RegionId: annotation2.RegionId, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + So(items[0].Id, ShouldEqual, annotation2.Id) + }) + Convey("Should not find any when item is outside time range", func() { items, err := repo.Find(&annotations.ItemQuery{ OrgId: 1, diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index bc60165ca69..3ede1a03e8c 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -1,12 +1,14 @@ package sqlstore import ( + "strings" "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" + "github.com/grafana/grafana/pkg/util" ) func init() { @@ -17,9 +19,15 @@ func init() { bus.AddHandler("sql", SearchDashboards) bus.AddHandler("sql", GetDashboardTags) bus.AddHandler("sql", GetDashboardSlugById) + bus.AddHandler("sql", GetDashboardUIDById) bus.AddHandler("sql", GetDashboardsByPluginId) + bus.AddHandler("sql", GetFoldersForSignedInUser) + bus.AddHandler("sql", GetDashboardPermissionsForUser) + bus.AddHandler("sql", GetDashboardsBySlug) } +var generateNewUid func() string = util.GenerateShortUid + func SaveDashboard(cmd *m.SaveDashboardCommand) error { return inTransaction(func(sess *DBSession) error { return saveDashboard(sess, cmd) @@ -30,8 +38,9 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { dash := cmd.GetDashboardModel() // try get existing dashboard - var existing, sameTitle m.Dashboard - if dash.Id > 0 { + var existing m.Dashboard + + if dash.Id != 0 { dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) if err != nil { return err @@ -53,25 +62,40 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { if existing.PluginId != "" && cmd.Overwrite == false { return m.UpdatePluginDashboardError{PluginId: existing.PluginId} } - } + } else if dash.Uid != "" { + var sameUid m.Dashboard + sameUidExists, err := sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&sameUid) + if err != nil { + return err + } - sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle) - if err != nil { - return err - } - - if sameTitleExists { - // another dashboard with same name - if dash.Id != sameTitle.Id { - if cmd.Overwrite { - dash.Id = sameTitle.Id - dash.Version = sameTitle.Version - } else { - return m.ErrDashboardWithSameNameExists + if sameUidExists { + // another dashboard with same uid + if dash.Id != sameUid.Id { + if cmd.Overwrite { + dash.Id = sameUid.Id + dash.Version = sameUid.Version + } else { + return m.ErrDashboardWithSameUIDExists + } } } } + if dash.Uid == "" { + uid, err := generateNewDashboardUid(sess, dash.OrgId) + if err != nil { + return err + } + dash.Uid = uid + dash.Data.Set("uid", uid) + } + + err := guaranteeDashboardNameIsUniqueInFolder(sess, dash) + if err != nil { + return err + } + err = setHasAcl(sess, dash) if err != nil { return err @@ -93,7 +117,7 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { dash.Updated = cmd.UpdatedAt } - affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash) + affectedRows, err = sess.MustCols("folder_id", "has_acl").ID(dash.Id).Update(dash) } if err != nil { @@ -143,6 +167,40 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { return err } +func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) { + for i := 0; i < 3; i++ { + uid := generateNewUid() + + exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.Dashboard{}) + if err != nil { + return "", err + } + + if !exists { + return uid, nil + } + } + + return "", m.ErrDashboardFailedGenerateUniqueUid +} + +func guaranteeDashboardNameIsUniqueInFolder(sess *DBSession, dash *m.Dashboard) error { + var sameNameInFolder m.Dashboard + sameNameInFolderExist, err := sess.Where("org_id=? AND title=? AND folder_id = ? AND uid <> ?", + dash.OrgId, dash.Title, dash.FolderId, dash.Uid). + Get(&sameNameInFolder) + + if err != nil { + return err + } + + if sameNameInFolderExist { + return m.ErrDashboardWithSameNameInFolderExists + } + + return nil +} + func setHasAcl(sess *DBSession, dash *m.Dashboard) error { // check if parent has acl if dash.FolderId > 0 { @@ -169,7 +227,7 @@ func setHasAcl(sess *DBSession, dash *m.Dashboard) error { } func GetDashboard(query *m.GetDashboardQuery) error { - dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id} + dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid} has, err := x.Get(&dashboard) if err != nil { @@ -179,17 +237,20 @@ func GetDashboard(query *m.GetDashboardQuery) error { } dashboard.Data.Set("id", dashboard.Id) + dashboard.Data.Set("uid", dashboard.Uid) query.Result = &dashboard return nil } type DashboardSearchProjection struct { Id int64 + Uid string Title string Slug string Term string IsFolder bool FolderId int64 + FolderUid string FolderSlug string FolderTitle string } @@ -262,15 +323,21 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard if !exists { hit = &search.Hit{ Id: item.Id, + Uid: item.Uid, Title: item.Title, Uri: "db/" + item.Slug, - Slug: item.Slug, + Url: m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug), Type: getHitType(item), FolderId: item.FolderId, + FolderUid: item.FolderUid, FolderTitle: item.FolderTitle, - FolderSlug: item.FolderSlug, Tags: []string{}, } + + if item.FolderId > 0 { + hit.FolderUrl = m.GetFolderUrl(item.FolderUid, item.FolderSlug) + } + query.Result = append(query.Result, hit) hits[item.Id] = hit } @@ -295,6 +362,54 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error { return err } +func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error { + query.Result = make([]*m.DashboardFolder, 0) + var err error + + if query.SignedInUser.OrgRole == m.ROLE_ADMIN { + sql := `SELECT distinct d.id, d.title + FROM dashboard AS d WHERE d.is_folder = ? AND d.org_id = ? + ORDER BY d.title ASC` + + err = x.Sql(sql, dialect.BooleanStr(true), query.OrgId).Find(&query.Result) + } else { + params := make([]interface{}, 0) + sql := `SELECT distinct d.id, d.title + FROM dashboard AS d + LEFT JOIN dashboard_acl AS da ON d.id = da.dashboard_id + LEFT JOIN team_member AS ugm ON ugm.team_id = da.team_id + LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ? + LEFT JOIN org_user ouRole ON ouRole.role = 'Editor' AND ouRole.user_id = ? AND ouRole.org_id = ?` + params = append(params, query.SignedInUser.UserId) + params = append(params, query.SignedInUser.UserId) + params = append(params, query.OrgId) + + sql += ` WHERE + d.org_id = ? AND + d.is_folder = ? AND + ( + (d.has_acl = ? AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) + OR (d.has_acl = ? AND ouRole.id IS NOT NULL) + )` + params = append(params, query.OrgId) + params = append(params, dialect.BooleanStr(true)) + params = append(params, dialect.BooleanStr(true)) + params = append(params, query.SignedInUser.UserId) + params = append(params, query.SignedInUser.UserId) + params = append(params, dialect.BooleanStr(false)) + + if len(query.Title) > 0 { + sql += " AND d.title " + dialect.LikeStr() + " ?" + params = append(params, "%"+query.Title+"%") + } + + sql += ` ORDER BY d.title ASC` + err = x.Sql(sql, params...).Find(&query.Result) + } + + return err +} + func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { return inTransaction(func(sess *DBSession) error { dashboard := m.Dashboard{Id: cmd.Id, OrgId: cmd.OrgId} @@ -348,6 +463,78 @@ func GetDashboards(query *m.GetDashboardsQuery) error { return nil } +// GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s) +// The function takes in a list of dashboard ids and the user id and role +func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery) error { + if len(query.DashboardIds) == 0 { + return m.ErrCommandValidationFailed + } + + if query.OrgRole == m.ROLE_ADMIN { + var permissions = make([]*m.DashboardPermissionForUser, 0) + for _, d := range query.DashboardIds { + permissions = append(permissions, &m.DashboardPermissionForUser{ + DashboardId: d, + Permission: m.PERMISSION_ADMIN, + PermissionName: m.PERMISSION_ADMIN.String(), + }) + } + query.Result = permissions + + return nil + } + + params := make([]interface{}, 0) + + // check dashboards that have ACLs via user id, team id or role + sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission + FROM dashboard AS d + LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id + LEFT JOIN team_member as ugm on ugm.team_id = da.team_id + LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ? + ` + params = append(params, query.UserId) + + //check the user's role for dashboards that do not have hasAcl set + sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?` + params = append(params, query.UserId) + params = append(params, query.OrgId) + + sql += ` + LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS role + UNION SELECT 2 AS permission, 'Editor' AS role + UNION SELECT 4 AS permission, 'Admin' AS role) pt ON ouRole.role = pt.role + WHERE + d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) ` + for _, id := range query.DashboardIds { + params = append(params, id) + } + + sql += ` AND + d.org_id = ? AND + ( + (d.has_acl = ? AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) + OR (d.has_acl = ? AND ouRole.id IS NOT NULL) + ) + group by d.id + order by d.id asc` + params = append(params, query.OrgId) + params = append(params, dialect.BooleanStr(true)) + params = append(params, query.UserId) + params = append(params, query.UserId) + params = append(params, dialect.BooleanStr(false)) + + x.ShowSQL(true) + err := x.Sql(sql, params...).Find(&query.Result) + x.ShowSQL(false) + + for _, p := range query.Result { + p.PermissionName = p.Permission.String() + } + + return err +} + func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error { var dashboards = make([]*m.Dashboard, 0) whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false) @@ -370,7 +557,7 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error { var rawSql = `SELECT slug from dashboard WHERE Id=?` var slug = DashboardSlugDTO{} - exists, err := x.Sql(rawSql, query.Id).Get(&slug) + exists, err := x.SQL(rawSql, query.Id).Get(&slug) if err != nil { return err @@ -381,3 +568,31 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error { query.Result = slug.Slug return nil } + +func GetDashboardsBySlug(query *m.GetDashboardsBySlugQuery) error { + var dashboards []*m.Dashboard + + if err := x.Where("org_id=? AND slug=?", query.OrgId, query.Slug).Find(&dashboards); err != nil { + return err + } + + query.Result = dashboards + return nil +} + +func GetDashboardUIDById(query *m.GetDashboardRefByIdQuery) error { + var rawSql = `SELECT uid, slug from dashboard WHERE Id=?` + + us := &m.DashboardRef{} + + exists, err := x.SQL(rawSql, query.Id).Get(us) + + if err != nil { + return err + } else if exists == false { + return m.ErrDashboardNotFound + } + + query.Result = us + return nil +} diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index 3b0c89e02ef..829182a8195 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -113,6 +113,7 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error { }) } +// RemoveDashboardAcl removes a specified permission from the dashboard acl func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error { return inTransaction(func(sess *DBSession) error { var rawSQL = "DELETE FROM " + dialect.Quote("dashboard_acl") + " WHERE org_id =? and id=?" @@ -125,15 +126,16 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error { }) } +// GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three +// different places. +// 1) Permissions for the dashboard +// 2) permissions for its parent folder +// 3) if no specific permissions have been set for the dashboard or its parent folder then get the default permissions func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { - dashboardFilter := fmt.Sprintf(`IN ( - SELECT %d - UNION - SELECT folder_id from dashboard where id = %d - )`, query.DashboardId, query.DashboardId) + var err error - rawSQL := ` - SELECT + if query.DashboardId == 0 { + sql := `SELECT da.id, da.org_id, da.dashboard_id, @@ -143,43 +145,85 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error { da.role, da.created, da.updated, - u.login AS user_login, - u.email AS user_email, - ug.name AS team - FROM` + dialect.Quote("dashboard_acl") + ` as da - LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id - LEFT OUTER JOIN team ug on ug.id = da.team_id - WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ? + '' as user_login, + '' as user_email, + '' as team, + '' as title, + '' as slug, + '' as uid,` + + dialect.BooleanStr(false) + ` AS is_folder + FROM dashboard_acl as da + WHERE da.dashboard_id = -1` + query.Result = make([]*m.DashboardAclInfoDTO, 0) + err = x.SQL(sql).Find(&query.Result) - -- Also include default permission if has_acl = 0 + } else { + dashboardFilter := fmt.Sprintf(`IN ( + SELECT %d + UNION + SELECT folder_id from dashboard where id = %d + )`, query.DashboardId, query.DashboardId) - UNION - SELECT - da.id, - da.org_id, - da.dashboard_id, - da.user_id, - da.team_id, - da.permission, - da.role, - da.created, - da.updated, - '' as user_login, - '' as user_email, - '' as team - FROM dashboard_acl as da, - dashboard as dash - LEFT JOIN dashboard folder on dash.folder_id = folder.id - WHERE - dash.id = ? AND ( - dash.has_acl = ` + dialect.BooleanStr(false) + ` or - folder.has_acl = ` + dialect.BooleanStr(false) + ` - ) AND - da.dashboard_id = -1 - ` + rawSQL := ` + -- get permissions for the dashboard and its parent folder + SELECT + da.id, + da.org_id, + da.dashboard_id, + da.user_id, + da.team_id, + da.permission, + da.role, + da.created, + da.updated, + u.login AS user_login, + u.email AS user_email, + ug.name AS team, + d.title, + d.slug, + d.uid, + d.is_folder + FROM` + dialect.Quote("dashboard_acl") + ` as da + LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id + LEFT OUTER JOIN team ug on ug.id = da.team_id + LEFT OUTER JOIN dashboard d on da.dashboard_id = d.id + WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ? - query.Result = make([]*m.DashboardAclInfoDTO, 0) - err := x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result) + -- Also include default permissions if folder or dashboard field "has_acl" is false + + UNION + SELECT + da.id, + da.org_id, + da.dashboard_id, + da.user_id, + da.team_id, + da.permission, + da.role, + da.created, + da.updated, + '' as user_login, + '' as user_email, + '' as team, + folder.title, + folder.slug, + folder.uid, + folder.is_folder + FROM dashboard_acl as da, + dashboard as dash + LEFT OUTER JOIN dashboard folder on dash.folder_id = folder.id + WHERE + dash.id = ? AND ( + dash.has_acl = ` + dialect.BooleanStr(false) + ` or + folder.has_acl = ` + dialect.BooleanStr(false) + ` + ) AND + da.dashboard_id = -1 + ORDER BY 1 ASC + ` + + query.Result = make([]*m.DashboardAclInfoDTO, 0) + err = x.SQL(rawSQL, query.OrgId, query.DashboardId).Find(&query.Result) + } for _, p := range query.Result { p.PermissionName = p.Permission.String() diff --git a/pkg/services/sqlstore/dashboard_acl_test.go b/pkg/services/sqlstore/dashboard_acl_test.go index bb6363883d6..8b712c73ece 100644 --- a/pkg/services/sqlstore/dashboard_acl_test.go +++ b/pkg/services/sqlstore/dashboard_acl_test.go @@ -232,5 +232,23 @@ func TestDashboardAclDataAccess(t *testing.T) { }) }) + + Convey("Given a root folder", func() { + var rootFolderId int64 = 0 + + Convey("When reading dashboard acl should return default permissions", func() { + query := m.GetDashboardAclInfoListQuery{DashboardId: rootFolderId, OrgId: 1} + + err := GetDashboardAclInfoList(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + defaultPermissionsId := -1 + So(query.Result[0].DashboardId, ShouldEqual, defaultPermissionsId) + So(*query.Result[0].Role, ShouldEqual, m.ROLE_VIEWER) + So(query.Result[1].DashboardId, ShouldEqual, defaultPermissionsId) + So(*query.Result[1].Role, ShouldEqual, m.ROLE_EDITOR) + }) + }) }) } diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/sqlstore/dashboard_folder_test.go new file mode 100644 index 00000000000..4818deaae14 --- /dev/null +++ b/pkg/services/sqlstore/dashboard_folder_test.go @@ -0,0 +1,350 @@ +package sqlstore + +import ( + "testing" + + "github.com/go-xorm/xorm" + . "github.com/smartystreets/goconvey/convey" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/search" +) + +func TestDashboardFolderDataAccess(t *testing.T) { + var x *xorm.Engine + + Convey("Testing DB", t, func() { + x = InitTestDB(t) + + Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() { + folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp") + dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp") + childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp") + insertTestDashboard("test dash 45", 1, folder.Id, false, "prod") + + currentUser := createUser("viewer", "Viewer", false) + + Convey("and no acls are set", func() { + Convey("should return all dashboards", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder.Id) + So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("and acl is set for dashboard folder", func() { + var otherUser int64 = 999 + updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT) + + Convey("should not return folder", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + }) + + Convey("when the user is given permission", func() { + updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT) + + Convey("should be able to access folder", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder.Id) + So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("when the user is an admin", func() { + Convey("should be able to access folder", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{ + UserId: currentUser.Id, + OrgId: 1, + OrgRole: m.ROLE_ADMIN, + }, + OrgId: 1, + DashboardIds: []int64{folder.Id, dashInRoot.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder.Id) + So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + }) + }) + }) + + Convey("and acl is set for dashboard child and folder has all permissions removed", func() { + var otherUser int64 = 999 + aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT) + removeAcl(aclId) + updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT) + + Convey("should not return folder or child", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + }) + + Convey("when the user is given permission to child", func() { + updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT) + + Convey("should be able to search for child dashboard but not folder", func() { + query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, childDash.Id) + So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("when the user is an admin", func() { + Convey("should be able to search for child dash and folder", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{ + UserId: currentUser.Id, + OrgId: 1, + OrgRole: m.ROLE_ADMIN, + }, + OrgId: 1, + DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 3) + So(query.Result[0].Id, ShouldEqual, folder.Id) + So(query.Result[1].Id, ShouldEqual, childDash.Id) + So(query.Result[2].Id, ShouldEqual, dashInRoot.Id) + }) + }) + }) + }) + + Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() { + folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod") + folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod") + dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod") + childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod") + childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod") + + currentUser := createUser("viewer", "Viewer", false) + var rootFolderId int64 = 0 + + Convey("and one folder is expanded, the other collapsed", func() { + Convey("should return dashboards in root and expanded folder", func() { + query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1} + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 4) + So(query.Result[0].Id, ShouldEqual, folder1.Id) + So(query.Result[1].Id, ShouldEqual, folder2.Id) + So(query.Result[2].Id, ShouldEqual, childDash1.Id) + So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("and acl is set for one dashboard folder", func() { + var otherUser int64 = 999 + updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT) + + Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() { + movedDash := moveDashboard(1, childDash2.Data, folder1.Id) + So(movedDash.HasAcl, ShouldBeTrue) + + Convey("should not return folder with acl or its children", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + OrgId: 1, + DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() { + movedDash := moveDashboard(1, childDash1.Data, folder2.Id) + So(movedDash.HasAcl, ShouldBeFalse) + + Convey("should return folder without acl and its children", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + OrgId: 1, + DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 4) + So(query.Result[0].Id, ShouldEqual, folder2.Id) + So(query.Result[1].Id, ShouldEqual, childDash1.Id) + So(query.Result[2].Id, ShouldEqual, childDash2.Id) + So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) + }) + }) + + Convey("and a dashboard with an acl is moved to the folder without an acl", func() { + updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT) + movedDash := moveDashboard(1, childDash1.Data, folder2.Id) + So(movedDash.HasAcl, ShouldBeTrue) + + Convey("should return folder without acl but not the dashboard with acl", func() { + query := &search.FindPersistedDashboardsQuery{ + SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, + OrgId: 1, + DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, + } + err := SearchDashboards(query) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 3) + So(query.Result[0].Id, ShouldEqual, folder2.Id) + So(query.Result[1].Id, ShouldEqual, childDash2.Id) + So(query.Result[2].Id, ShouldEqual, dashInRoot.Id) + }) + }) + }) + }) + + Convey("Given two dashboard folders", func() { + + folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod") + folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod") + insertTestDashboard("folder in another org", 2, 0, true, "prod") + + adminUser := createUser("admin", "Admin", true) + editorUser := createUser("editor", "Editor", false) + viewerUser := createUser("viewer", "Viewer", false) + + Convey("Admin users", func() { + Convey("Should have write access to all dashboard folders in their org", func() { + query := m.GetFoldersForSignedInUserQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN}, + } + + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder1.Id) + So(query.Result[1].Id, ShouldEqual, folder2.Id) + }) + + Convey("should have write access to all folders and dashboards", func() { + query := m.GetDashboardPermissionsForUserQuery{ + DashboardIds: []int64{folder1.Id, folder2.Id}, + OrgId: 1, + UserId: adminUser.Id, + OrgRole: m.ROLE_ADMIN, + } + + err := GetDashboardPermissionsForUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].DashboardId, ShouldEqual, folder1.Id) + So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN) + So(query.Result[1].DashboardId, ShouldEqual, folder2.Id) + So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_ADMIN) + }) + }) + + Convey("Editor users", func() { + query := m.GetFoldersForSignedInUserQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR}, + } + + Convey("Should have write access to all dashboard folders with default ACL", func() { + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].Id, ShouldEqual, folder1.Id) + So(query.Result[1].Id, ShouldEqual, folder2.Id) + }) + + Convey("should have edit access to folders with default ACL", func() { + query := m.GetDashboardPermissionsForUserQuery{ + DashboardIds: []int64{folder1.Id, folder2.Id}, + OrgId: 1, + UserId: editorUser.Id, + OrgRole: m.ROLE_EDITOR, + } + + err := GetDashboardPermissionsForUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].DashboardId, ShouldEqual, folder1.Id) + So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT) + So(query.Result[1].DashboardId, ShouldEqual, folder2.Id) + So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_EDIT) + }) + + Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() { + updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW) + + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, folder2.Id) + }) + + }) + + Convey("Viewer users", func() { + query := m.GetFoldersForSignedInUserQuery{ + OrgId: 1, + SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER}, + } + + Convey("Should have no write access to any dashboard folders with default ACL", func() { + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 0) + }) + + Convey("should have view access to folders with default ACL", func() { + query := m.GetDashboardPermissionsForUserQuery{ + DashboardIds: []int64{folder1.Id, folder2.Id}, + OrgId: 1, + UserId: viewerUser.Id, + OrgRole: m.ROLE_VIEWER, + } + + err := GetDashboardPermissionsForUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 2) + So(query.Result[0].DashboardId, ShouldEqual, folder1.Id) + So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_VIEW) + So(query.Result[1].DashboardId, ShouldEqual, folder2.Id) + So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_VIEW) + }) + + Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() { + updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT) + + err := GetFoldersForSignedInUser(&query) + So(err, ShouldBeNil) + + So(len(query.Result), ShouldEqual, 1) + So(query.Result[0].Id, ShouldEqual, folder1.Id) + }) + }) + }) + }) +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index a552bd0546a..97ab5472d94 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -1,15 +1,16 @@ package sqlstore import ( + "fmt" "testing" "github.com/go-xorm/xorm" - . "github.com/smartystreets/goconvey/convey" - "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + . "github.com/smartystreets/goconvey/convey" ) func TestDashboardDataAccess(t *testing.T) { @@ -30,15 +31,33 @@ func TestDashboardDataAccess(t *testing.T) { So(savedDash.Id, ShouldNotEqual, 0) So(savedDash.IsFolder, ShouldBeFalse) So(savedDash.FolderId, ShouldBeGreaterThan, 0) + So(len(savedDash.Uid), ShouldBeGreaterThan, 0) So(savedFolder.Title, ShouldEqual, "1 test dash folder") So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder") So(savedFolder.Id, ShouldNotEqual, 0) So(savedFolder.IsFolder, ShouldBeTrue) So(savedFolder.FolderId, ShouldEqual, 0) + So(len(savedFolder.Uid), ShouldBeGreaterThan, 0) }) - Convey("Should be able to get dashboard", func() { + Convey("Should be able to get dashboard by id", func() { + query := m.GetDashboardQuery{ + Id: savedDash.Id, + OrgId: 1, + } + + err := GetDashboard(&query) + So(err, ShouldBeNil) + + So(query.Result.Title, ShouldEqual, "test dash 23") + So(query.Result.Slug, ShouldEqual, "test-dash-23") + So(query.Result.Id, ShouldEqual, savedDash.Id) + So(query.Result.Uid, ShouldEqual, savedDash.Uid) + So(query.Result.IsFolder, ShouldBeFalse) + }) + + Convey("Should be able to get dashboard by slug", func() { query := m.GetDashboardQuery{ Slug: "test-dash-23", OrgId: 1, @@ -49,6 +68,24 @@ func TestDashboardDataAccess(t *testing.T) { So(query.Result.Title, ShouldEqual, "test dash 23") So(query.Result.Slug, ShouldEqual, "test-dash-23") + So(query.Result.Id, ShouldEqual, savedDash.Id) + So(query.Result.Uid, ShouldEqual, savedDash.Uid) + So(query.Result.IsFolder, ShouldBeFalse) + }) + + Convey("Should be able to get dashboard by uid", func() { + query := m.GetDashboardQuery{ + Uid: savedDash.Uid, + OrgId: 1, + } + + err := GetDashboard(&query) + So(err, ShouldBeNil) + + So(query.Result.Title, ShouldEqual, "test dash 23") + So(query.Result.Slug, ShouldEqual, "test-dash-23") + So(query.Result.Id, ShouldEqual, savedDash.Id) + So(query.Result.Uid, ShouldEqual, savedDash.Uid) So(query.Result.IsFolder, ShouldBeFalse) }) @@ -109,6 +146,8 @@ func TestDashboardDataAccess(t *testing.T) { So(len(query.Result), ShouldEqual, 1) hit := query.Result[0] So(hit.Type, ShouldEqual, search.DashHitFolder) + So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) + So(hit.FolderTitle, ShouldEqual, "") }) Convey("Should be able to search for a dashboard folder's children", func() { @@ -124,6 +163,11 @@ func TestDashboardDataAccess(t *testing.T) { So(len(query.Result), ShouldEqual, 2) hit := query.Result[0] So(hit.Id, ShouldEqual, savedDash.Id) + So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug)) + So(hit.FolderId, ShouldEqual, savedFolder.Id) + So(hit.FolderUid, ShouldEqual, savedFolder.Uid) + So(hit.FolderTitle, ShouldEqual, savedFolder.Title) + So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) }) Convey("Should be able to search for dashboard by dashboard ids", func() { @@ -157,18 +201,170 @@ func TestDashboardDataAccess(t *testing.T) { }) }) - Convey("Should not be able to save dashboard with same name", func() { + Convey("Should be able to save dashboards with same name in different folders", func() { + firstSaveCmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": "test dash folder and title", + "tags": []interface{}{}, + "uid": "randomHash", + }), + FolderId: 3, + } + + err := SaveDashboard(&firstSaveCmd) + So(err, ShouldBeNil) + + secondSaveCmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": "test dash folder and title", + "tags": []interface{}{}, + "uid": "moreRandomHash", + }), + FolderId: 1, + } + + err = SaveDashboard(&secondSaveCmd) + So(err, ShouldBeNil) + }) + + Convey("Should not be able to save dashboard with same name in the same folder", func() { + firstSaveCmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": "test dash folder and title", + "tags": []interface{}{}, + "uid": "randomHash", + }), + FolderId: 3, + } + + err := SaveDashboard(&firstSaveCmd) + So(err, ShouldBeNil) + + secondSaveCmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": "test dash folder and title", + "tags": []interface{}{}, + "uid": "moreRandomHash", + }), + FolderId: 3, + } + + err = SaveDashboard(&secondSaveCmd) + So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists) + }) + + Convey("Should not be able to save dashboard with same uid", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, Dashboard: simplejson.NewFromAny(map[string]interface{}{ "id": nil, "title": "test dash 23", + "uid": "dsfalkjngailuedt", + }), + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + err = SaveDashboard(&cmd) + So(err, ShouldNotBeNil) + }) + + Convey("Should be able to update dashboard with the same title and folder id", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": "randomHash", + "title": "folderId", + "style": "light", + "tags": []interface{}{}, + }), + FolderId: 2, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + So(cmd.Result.FolderId, ShouldEqual, 2) + + cmd = m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": cmd.Result.Id, + "uid": "randomHash", + "title": "folderId", + "style": "dark", + "version": cmd.Result.Version, + "tags": []interface{}{}, + }), + FolderId: 2, + } + + err = SaveDashboard(&cmd) + So(err, ShouldBeNil) + }) + + Convey("Should not be able to update using just uid", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDash.Uid, + "title": "folderId", + "version": savedDash.Version, + "tags": []interface{}{}, + }), + FolderId: savedDash.FolderId, + } + + err := SaveDashboard(&cmd) + So(err, ShouldEqual, m.ErrDashboardWithSameUIDExists) + }) + + Convey("Should be able to update using just uid with overwrite", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "uid": savedDash.Uid, + "title": "folderId", + "version": savedDash.Version, + "tags": []interface{}{}, + }), + FolderId: savedDash.FolderId, + Overwrite: true, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + }) + + Convey("Should retry generation of uid once if it fails.", func() { + timesCalled := 0 + generateNewUid = func() string { + timesCalled += 1 + if timesCalled <= 2 { + return savedDash.Uid + } else { + return util.GenerateShortUid() + } + } + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "new dash 12334", "tags": []interface{}{}, }), } err := SaveDashboard(&cmd) - So(err, ShouldNotBeNil) + So(err, ShouldBeNil) + + generateNewUid = util.GenerateShortUid }) Convey("Should be able to update dashboard and remove folderId", func() { @@ -260,205 +456,6 @@ func TestDashboardDataAccess(t *testing.T) { }) }) - Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() { - folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp") - dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp") - childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp") - insertTestDashboard("test dash 45", 1, folder.Id, false, "prod") - - currentUser := createUser("viewer", "Viewer", false) - - Convey("and no acls are set", func() { - Convey("should return all dashboards", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, folder.Id) - So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) - }) - }) - - Convey("and acl is set for dashboard folder", func() { - var otherUser int64 = 999 - updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT) - - Convey("should not return folder", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) - }) - - Convey("when the user is given permission", func() { - updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT) - - Convey("should be able to access folder", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}} - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, folder.Id) - So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) - }) - }) - - Convey("when the user is an admin", func() { - Convey("should be able to access folder", func() { - query := &search.FindPersistedDashboardsQuery{ - SignedInUser: &m.SignedInUser{ - UserId: currentUser.Id, - OrgId: 1, - OrgRole: m.ROLE_ADMIN, - }, - OrgId: 1, - DashboardIds: []int64{folder.Id, dashInRoot.Id}, - } - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, folder.Id) - So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) - }) - }) - }) - - Convey("and acl is set for dashboard child and folder has all permissions removed", func() { - var otherUser int64 = 999 - aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT) - removeAcl(aclId) - updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT) - - Convey("should not return folder or child", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) - }) - - Convey("when the user is given permission to child", func() { - updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT) - - Convey("should be able to search for child dashboard but not folder", func() { - query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}} - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, childDash.Id) - So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) - }) - }) - - Convey("when the user is an admin", func() { - Convey("should be able to search for child dash and folder", func() { - query := &search.FindPersistedDashboardsQuery{ - SignedInUser: &m.SignedInUser{ - UserId: currentUser.Id, - OrgId: 1, - OrgRole: m.ROLE_ADMIN, - }, - OrgId: 1, - DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id}, - } - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 3) - So(query.Result[0].Id, ShouldEqual, folder.Id) - So(query.Result[1].Id, ShouldEqual, childDash.Id) - So(query.Result[2].Id, ShouldEqual, dashInRoot.Id) - }) - }) - }) - }) - - Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() { - folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod") - folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod") - dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod") - childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod") - childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod") - - currentUser := createUser("viewer", "Viewer", false) - var rootFolderId int64 = 0 - - Convey("and one folder is expanded, the other collapsed", func() { - Convey("should return dashboards in root and expanded folder", func() { - query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1} - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 4) - So(query.Result[0].Id, ShouldEqual, folder1.Id) - So(query.Result[1].Id, ShouldEqual, folder2.Id) - So(query.Result[2].Id, ShouldEqual, childDash1.Id) - So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) - }) - }) - - Convey("and acl is set for one dashboard folder", func() { - var otherUser int64 = 999 - updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT) - - Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() { - movedDash := moveDashboard(1, childDash2.Data, folder1.Id) - So(movedDash.HasAcl, ShouldBeTrue) - - Convey("should not return folder with acl or its children", func() { - query := &search.FindPersistedDashboardsQuery{ - SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, - OrgId: 1, - DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, - } - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) - }) - }) - - Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() { - movedDash := moveDashboard(1, childDash1.Data, folder2.Id) - So(movedDash.HasAcl, ShouldBeFalse) - - Convey("should return folder without acl and its children", func() { - query := &search.FindPersistedDashboardsQuery{ - SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, - OrgId: 1, - DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, - } - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 4) - So(query.Result[0].Id, ShouldEqual, folder2.Id) - So(query.Result[1].Id, ShouldEqual, childDash1.Id) - So(query.Result[2].Id, ShouldEqual, childDash2.Id) - So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) - }) - }) - - Convey("and a dashboard with an acl is moved to the folder without an acl", func() { - updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT) - movedDash := moveDashboard(1, childDash1.Data, folder2.Id) - So(movedDash.HasAcl, ShouldBeTrue) - - Convey("should return folder without acl but not the dashboard with acl", func() { - query := &search.FindPersistedDashboardsQuery{ - SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, - OrgId: 1, - DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id}, - } - err := SearchDashboards(query) - So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 3) - So(query.Result[0].Id, ShouldEqual, folder2.Id) - So(query.Result[1].Id, ShouldEqual, childDash2.Id) - So(query.Result[2].Id, ShouldEqual, dashInRoot.Id) - }) - }) - }) - }) - Convey("Given a plugin with imported dashboards", func() { pluginId := "test-app" diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go index 6ed37cd6904..e20ac897b3d 100644 --- a/pkg/services/sqlstore/dashboard_version_test.go +++ b/pkg/services/sqlstore/dashboard_version_test.go @@ -12,7 +12,7 @@ import ( ) func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) { - data["title"] = dashboard.Title + data["uid"] = dashboard.Uid saveCmd := m.SaveDashboardCommand{ OrgId: dashboard.OrgId, @@ -44,7 +44,7 @@ func TestGetDashboardVersion(t *testing.T) { dashCmd := m.GetDashboardQuery{ OrgId: savedDash.OrgId, - Slug: savedDash.Slug, + Uid: savedDash.Uid, } err = GetDashboard(&dashCmd) diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go new file mode 100644 index 00000000000..805d726df48 --- /dev/null +++ b/pkg/services/sqlstore/login_attempt.go @@ -0,0 +1,91 @@ +package sqlstore + +import ( + "strconv" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +var getTimeNow = time.Now + +func init() { + bus.AddHandler("sql", CreateLoginAttempt) + bus.AddHandler("sql", DeleteOldLoginAttempts) + bus.AddHandler("sql", GetUserLoginAttemptCount) +} + +func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error { + return inTransaction(func(sess *DBSession) error { + loginAttempt := m.LoginAttempt{ + Username: cmd.Username, + IpAddress: cmd.IpAddress, + Created: getTimeNow(), + } + + if _, err := sess.Insert(&loginAttempt); err != nil { + return err + } + + cmd.Result = loginAttempt + + return nil + }) +} + +func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error { + return inTransaction(func(sess *DBSession) error { + var maxId int64 + sql := "SELECT max(id) as id FROM login_attempt WHERE created < " + dialect.DateTimeFunc("?") + result, err := sess.Query(sql, cmd.OlderThan) + + if err != nil { + return err + } + + maxId = toInt64(result[0]["id"]) + + if maxId == 0 { + return nil + } + + sql = "DELETE FROM login_attempt WHERE id <= ?" + + if result, err := sess.Exec(sql, maxId); err != nil { + return err + } else if cmd.DeletedRows, err = result.RowsAffected(); err != nil { + return err + } + + return nil + }) +} + +func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error { + loginAttempt := new(m.LoginAttempt) + total, err := x. + Where("username = ?", query.Username). + And("created >="+dialect.DateTimeFunc("?"), query.Since). + Count(loginAttempt) + + if err != nil { + return err + } + + query.Result = total + return nil +} + +func toInt64(i interface{}) int64 { + switch i.(type) { + case []byte: + n, _ := strconv.ParseInt(string(i.([]byte)), 10, 64) + return n + case int: + return int64(i.(int)) + case int64: + return i.(int64) + } + return 0 +} diff --git a/pkg/services/sqlstore/login_attempt_test.go b/pkg/services/sqlstore/login_attempt_test.go new file mode 100644 index 00000000000..8008e2d8a62 --- /dev/null +++ b/pkg/services/sqlstore/login_attempt_test.go @@ -0,0 +1,125 @@ +package sqlstore + +import ( + "testing" + "time" + + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func mockTime(mock time.Time) time.Time { + getTimeNow = func() time.Time { return mock } + return mock +} + +func TestLoginAttempts(t *testing.T) { + Convey("Testing Login Attempts DB Access", t, func() { + InitTestDB(t) + + user := "user" + beginningOfTime := mockTime(time.Date(2017, 10, 22, 8, 0, 0, 0, time.Local)) + + err := CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + timePlusOneMinute := mockTime(beginningOfTime.Add(time.Minute * 1)) + + err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + timePlusTwoMinutes := mockTime(beginningOfTime.Add(time.Minute * 2)) + + err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + Convey("Should return a total count of zero login attempts when comparing since beginning of time + 2min and 1s", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusTwoMinutes.Add(time.Second * 1), + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 0) + }) + + Convey("Should return the total count of login attempts since beginning of time", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: beginningOfTime, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 3) + }) + + Convey("Should return the total count of login attempts since beginning of time + 1min", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusOneMinute, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 2) + }) + + Convey("Should return the total count of login attempts since beginning of time + 2min", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusTwoMinutes, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 1) + }) + + Convey("Should return deleted rows older than beginning of time", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: beginningOfTime, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 0) + }) + + Convey("Should return deleted rows older than beginning of time + 1min", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusOneMinute, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 1) + }) + + Convey("Should return deleted rows older than beginning of time + 2min", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusTwoMinutes, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 2) + }) + + Convey("Should return deleted rows older than beginning of time + 2min and 1s", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusTwoMinutes.Add(time.Second * 1), + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 3) + }) + }) +} diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index ccaa4d013d3..c281c6a8121 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -151,6 +151,31 @@ func addDashboardMigration(mg *Migrator) { Name: "has_acl", Type: DB_Bool, Nullable: false, Default: "0", })) + mg.AddMigration("Add column uid in dashboard", NewAddColumnMigration(dashboardV2, &Column{ + Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true, + })) + + mg.AddMigration("Update uid column values in dashboard", new(RawSqlMigration). + Sqlite("UPDATE dashboard SET uid=printf('%09d',id) WHERE uid IS NULL;"). + Postgres("UPDATE dashboard SET uid=lpad('' || id,9,'0') WHERE uid IS NULL;"). + Mysql("UPDATE dashboard SET uid=lpad(id,9,'0') WHERE uid IS NULL;")) + + mg.AddMigration("Add unique index dashboard_org_id_uid", NewAddIndexMigration(dashboardV2, &Index{ + Cols: []string{"org_id", "uid"}, Type: UniqueIndex, + })) + + mg.AddMigration("Remove unique index org_id_slug", NewDropIndexMigration(dashboardV2, &Index{ + Cols: []string{"org_id", "slug"}, Type: UniqueIndex, + })) + + mg.AddMigration("Update dashboard title length", NewTableCharsetMigration("dashboard", []*Column{ + {Name: "title", Type: DB_NVarchar, Length: 189, Nullable: false}, + })) + + mg.AddMigration("Add unique index for dashboard_org_id_title_folder_id", NewAddIndexMigration(dashboardV2, &Index{ + Cols: []string{"org_id", "folder_id", "title"}, Type: UniqueIndex, + })) + dashboardExtrasTable := Table{ Name: "dashboard_provisioning", Columns: []*Column{ diff --git a/pkg/services/sqlstore/migrations/login_attempt_mig.go b/pkg/services/sqlstore/migrations/login_attempt_mig.go new file mode 100644 index 00000000000..e576ccd1a50 --- /dev/null +++ b/pkg/services/sqlstore/migrations/login_attempt_mig.go @@ -0,0 +1,23 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addLoginAttemptMigrations(mg *Migrator) { + loginAttemptV1 := Table{ + Name: "login_attempt", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "username", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "ip_address", Type: DB_NVarchar, Length: 30, Nullable: false}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"username"}}, + }, + } + + // create table + mg.AddMigration("create login attempt table", NewAddTableMigration(loginAttemptV1)) + // add indices + mg.AddMigration("add index login_attempt.username", NewAddIndexMigration(loginAttemptV1, loginAttemptV1.Indices[0])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 8e9268779ef..282f98e7318 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -29,6 +29,7 @@ func AddMigrations(mg *Migrator) { addTeamMigrations(mg) addDashboardAclMigrations(mg) addTagMigration(mg) + addLoginAttemptMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 651405921d9..064b5981063 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -19,6 +19,7 @@ type Dialect interface { LikeStr() string Default(col *Column) string BooleanStr(bool) string + DateTimeFunc(string) string CreateIndexSql(tableName string, index *Index) string CreateTableSql(table *Table) string @@ -78,6 +79,10 @@ func (b *BaseDialect) Default(col *Column) string { return col.Default } +func (db *BaseDialect) DateTimeFunc(value string) string { + return value +} + func (b *BaseDialect) CreateTableSql(table *Table) string { var sql string sql = "CREATE TABLE IF NOT EXISTS " diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index fe1e781c8df..1a31cee4f5e 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -36,6 +36,10 @@ func (db *Sqlite3) BooleanStr(value bool) string { return "0" } +func (db *Sqlite3) DateTimeFunc(value string) string { + return "datetime(" + value + ")" +} + func (db *Sqlite3) SqlType(c *Column) string { switch c.Type { case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time: diff --git a/pkg/services/sqlstore/playlist.go b/pkg/services/sqlstore/playlist.go index 4b4536455da..67720cbadb8 100644 --- a/pkg/services/sqlstore/playlist.go +++ b/pkg/services/sqlstore/playlist.go @@ -15,15 +15,13 @@ func init() { } func CreatePlaylist(cmd *m.CreatePlaylistCommand) error { - var err error - playlist := m.Playlist{ Name: cmd.Name, Interval: cmd.Interval, OrgId: cmd.OrgId, } - _, err = x.Insert(&playlist) + _, err := x.Insert(&playlist) playlistItems := make([]m.PlaylistItem, 0) for _, item := range cmd.Items { @@ -43,7 +41,6 @@ func CreatePlaylist(cmd *m.CreatePlaylistCommand) error { } func UpdatePlaylist(cmd *m.UpdatePlaylistCommand) error { - var err error playlist := m.Playlist{ Id: cmd.Id, OrgId: cmd.OrgId, @@ -64,7 +61,7 @@ func UpdatePlaylist(cmd *m.UpdatePlaylistCommand) error { Interval: playlist.Interval, } - _, err = x.Id(cmd.Id).Cols("id", "name", "interval").Update(&playlist) + _, err := x.ID(cmd.Id).Cols("id", "name", "interval").Update(&playlist) if err != nil { return err @@ -100,7 +97,7 @@ func GetPlaylist(query *m.GetPlaylistByIdQuery) error { } playlist := m.Playlist{} - _, err := x.Id(query.Id).Get(&playlist) + _, err := x.ID(query.Id).Get(&playlist) query.Result = &playlist diff --git a/pkg/services/sqlstore/search_builder.go b/pkg/services/sqlstore/search_builder.go index ddf192da5ff..627074d5453 100644 --- a/pkg/services/sqlstore/search_builder.go +++ b/pkg/services/sqlstore/search_builder.go @@ -101,11 +101,13 @@ func (sb *SearchBuilder) buildSelect() { sb.sql.WriteString( `SELECT dashboard.id, + dashboard.uid, dashboard.title, dashboard.slug, dashboard_tag.term, dashboard.is_folder, dashboard.folder_id, + folder.uid as folder_uid, folder.slug as folder_slug, folder.title as folder_title FROM `) diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 4aa2ec27216..a33872ed687 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -12,7 +12,7 @@ type TestDB struct { } var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"} -var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"} +var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci&loc=Local"} var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} func CleanDB(x *xorm.Engine) { diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index 7f631859a1d..cfe2d88c82c 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -18,7 +18,7 @@ var activeUserTimeLimit time.Duration = time.Hour * 24 * 30 func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error { var rawSql = `SELECT COUNT(*) as count, type FROM data_source GROUP BY type` query.Result = make([]*m.DataSourceStats, 0) - err := x.Sql(rawSql).Find(&query.Result) + err := x.SQL(rawSql).Find(&query.Result) if err != nil { return err } @@ -30,36 +30,39 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error { var rawSql = `SELECT ( SELECT COUNT(*) - FROM ` + dialect.Quote("user") + ` - ) AS users, + FROM ` + dialect.Quote("user") + ` + ) AS users, ( SELECT COUNT(*) - FROM ` + dialect.Quote("org") + ` - ) AS orgs, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("dashboard") + ` - ) AS dashboards, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("data_source") + ` - ) AS datasources, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("playlist") + ` - ) AS playlists, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("alert") + ` - ) AS alerts, + FROM ` + dialect.Quote("org") + ` + ) AS orgs, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("dashboard") + ` + ) AS dashboards, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("data_source") + ` + ) AS datasources, + ( + SELECT COUNT(*) FROM ` + dialect.Quote("star") + ` + ) AS stars, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("playlist") + ` + ) AS playlists, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("alert") + ` + ) AS alerts, ( SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` where last_seen_at > ? - ) as active_users + ) as active_users ` activeUserDeadlineDate := time.Now().Add(-activeUserTimeLimit) var stats m.SystemStats - _, err := x.Sql(rawSql, activeUserDeadlineDate).Get(&stats) + _, err := x.SQL(rawSql, activeUserDeadlineDate).Get(&stats) if err != nil { return err } @@ -70,51 +73,51 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error { func GetAdminStats(query *m.GetAdminStatsQuery) error { var rawSql = `SELECT - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("user") + ` - ) AS users, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("org") + ` - ) AS orgs, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("dashboard") + ` - ) AS dashboards, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("dashboard_snapshot") + ` - ) AS snapshots, - ( - SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` )) - FROM ` + dialect.Quote("dashboard_tag") + ` - ) AS tags, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("data_source") + ` - ) AS datasources, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("playlist") + ` - ) AS playlists, - ( - SELECT COUNT(*) FROM ` + dialect.Quote("star") + ` - ) AS stars, - ( - SELECT COUNT(*) - FROM ` + dialect.Quote("alert") + ` - ) AS alerts, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("user") + ` + ) AS users, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("org") + ` + ) AS orgs, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("dashboard") + ` + ) AS dashboards, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("dashboard_snapshot") + ` + ) AS snapshots, + ( + SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` )) + FROM ` + dialect.Quote("dashboard_tag") + ` + ) AS tags, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("data_source") + ` + ) AS datasources, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("playlist") + ` + ) AS playlists, + ( + SELECT COUNT(*) FROM ` + dialect.Quote("star") + ` + ) AS stars, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("alert") + ` + ) AS alerts, ( SELECT COUNT(*) - from ` + dialect.Quote("user") + ` where last_seen_at > ? + from ` + dialect.Quote("user") + ` where last_seen_at > ? ) as active_users - ` + ` activeUserDeadlineDate := time.Now().Add(-activeUserTimeLimit) var stats m.AdminStats - _, err := x.Sql(rawSql, activeUserDeadlineDate).Get(&stats) + _, err := x.SQL(rawSql, activeUserDeadlineDate).Get(&stats) if err != nil { return err } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 8cdb94bd413..6ce80a69957 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -75,13 +75,14 @@ var ( EnforceDomain bool // Security settings. - SecretKey string - LogInRememberDays int - CookieUserName string - CookieRememberName string - DisableGravatar bool - EmailCodeValidMinutes int - DataProxyWhiteList map[string]bool + SecretKey string + LogInRememberDays int + CookieUserName string + CookieRememberName string + DisableGravatar bool + EmailCodeValidMinutes int + DataProxyWhiteList map[string]bool + DisableBruteForceLoginProtection bool // Snapshots ExternalSnapshotUrl string @@ -514,6 +515,7 @@ func NewConfigContext(args *CommandLineArgs) error { CookieUserName = security.Key("cookie_username").String() CookieRememberName = security.Key("cookie_remember_name").String() DisableGravatar = security.Key("disable_gravatar").MustBool(true) + DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) // read snapshots settings snapshots := Cfg.Section("snapshots") @@ -578,7 +580,7 @@ func NewConfigContext(args *CommandLineArgs) error { // PhantomJS rendering ImagesDir = filepath.Join(DataPath, "png") - PhantomDir = filepath.Join(HomePath, "vendor/phantomjs") + PhantomDir = filepath.Join(HomePath, "tools/phantomjs") analytics := Cfg.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) diff --git a/pkg/social/generic_oauth.go b/pkg/social/generic_oauth.go index 34830c2a348..2411e0006a2 100644 --- a/pkg/social/generic_oauth.go +++ b/pkg/social/generic_oauth.go @@ -1,19 +1,21 @@ package social import ( + "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/mail" + "regexp" "github.com/grafana/grafana/pkg/models" "golang.org/x/oauth2" ) -type GenericOAuth struct { - *oauth2.Config +type SocialGenericOAuth struct { + *SocialBase allowedDomains []string allowedOrganizations []string apiUrl string @@ -21,19 +23,19 @@ type GenericOAuth struct { teamIds []int } -func (s *GenericOAuth) Type() int { +func (s *SocialGenericOAuth) Type() int { return int(models.GENERIC) } -func (s *GenericOAuth) IsEmailAllowed(email string) bool { +func (s *SocialGenericOAuth) IsEmailAllowed(email string) bool { return isEmailAllowed(email, s.allowedDomains) } -func (s *GenericOAuth) IsSignupAllowed() bool { +func (s *SocialGenericOAuth) IsSignupAllowed() bool { return s.allowSignup } -func (s *GenericOAuth) IsTeamMember(client *http.Client) bool { +func (s *SocialGenericOAuth) IsTeamMember(client *http.Client) bool { if len(s.teamIds) == 0 { return true } @@ -54,7 +56,7 @@ func (s *GenericOAuth) IsTeamMember(client *http.Client) bool { return false } -func (s *GenericOAuth) IsOrganizationMember(client *http.Client) bool { +func (s *SocialGenericOAuth) IsOrganizationMember(client *http.Client) bool { if len(s.allowedOrganizations) == 0 { return true } @@ -75,7 +77,7 @@ func (s *GenericOAuth) IsOrganizationMember(client *http.Client) bool { return false } -func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) { +func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) { type Record struct { Email string `json:"email"` Primary bool `json:"primary"` @@ -116,7 +118,7 @@ func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) { return email, nil } -func (s *GenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error) { +func (s *SocialGenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error) { type Record struct { Id int `json:"id"` } @@ -141,7 +143,7 @@ func (s *GenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error) return ids, nil } -func (s *GenericOAuth) FetchOrganizations(client *http.Client) ([]string, error) { +func (s *SocialGenericOAuth) FetchOrganizations(client *http.Client) ([]string, error) { type Record struct { Login string `json:"login"` } @@ -176,17 +178,19 @@ type UserInfoJson struct { Attributes map[string][]string `json:"attributes"` } -func (s *GenericOAuth) UserInfo(client *http.Client) (*BasicUserInfo, error) { +func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data UserInfoJson - response, err := HttpGet(client, s.apiUrl) - if err != nil { - return nil, fmt.Errorf("Error getting user info: %s", err) - } + if s.extractToken(&data, token) != true { + response, err := HttpGet(client, s.apiUrl) + if err != nil { + return nil, fmt.Errorf("Error getting user info: %s", err) + } - err = json.Unmarshal(response.Body, &data) - if err != nil { - return nil, fmt.Errorf("Error getting user info: %s", err) + err = json.Unmarshal(response.Body, &data) + if err != nil { + return nil, fmt.Errorf("Error decoding user info JSON: %s", err) + } } name, err := s.extractName(data) @@ -221,7 +225,37 @@ func (s *GenericOAuth) UserInfo(client *http.Client) (*BasicUserInfo, error) { return userInfo, nil } -func (s *GenericOAuth) extractEmail(data UserInfoJson, client *http.Client) (string, error) { +func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Token) bool { + idToken := token.Extra("id_token") + if idToken == nil { + s.log.Debug("No id_token found", "token", token) + return false + } + + jwtRegexp := regexp.MustCompile("^([-_a-zA-Z0-9]+)[.]([-_a-zA-Z0-9]+)[.]([-_a-zA-Z0-9]+)$") + matched := jwtRegexp.FindStringSubmatch(idToken.(string)) + if matched == nil { + s.log.Debug("id_token is not in JWT format", "id_token", idToken.(string)) + return false + } + + payload, err := base64.RawURLEncoding.DecodeString(matched[2]) + if err != nil { + s.log.Error("Error base64 decoding id_token", "raw_payload", matched[2], "err", err) + return false + } + + err = json.Unmarshal(payload, data) + if err != nil { + s.log.Error("Error decoding id_token JSON", "payload", string(payload), "err", err) + return false + } + + s.log.Debug("Received id_token", "json", string(payload), "data", data) + return true +} + +func (s *SocialGenericOAuth) extractEmail(data UserInfoJson, client *http.Client) (string, error) { if data.Email != "" { return data.Email, nil } @@ -240,7 +274,7 @@ func (s *GenericOAuth) extractEmail(data UserInfoJson, client *http.Client) (str return s.FetchPrivateEmail(client) } -func (s *GenericOAuth) extractLogin(data UserInfoJson, email string) (string, error) { +func (s *SocialGenericOAuth) extractLogin(data UserInfoJson, email string) (string, error) { if data.Login != "" { return data.Login, nil } @@ -252,7 +286,7 @@ func (s *GenericOAuth) extractLogin(data UserInfoJson, email string) (string, er return email, nil } -func (s *GenericOAuth) extractName(data UserInfoJson) (string, error) { +func (s *SocialGenericOAuth) extractName(data UserInfoJson) (string, error) { if data.Name != "" { return data.Name, nil } diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go index 7e348e2363a..6f00cd2cd9e 100644 --- a/pkg/social/github_oauth.go +++ b/pkg/social/github_oauth.go @@ -12,7 +12,7 @@ import ( ) type SocialGithub struct { - *oauth2.Config + *SocialBase allowedDomains []string allowedOrganizations []string apiUrl string @@ -192,7 +192,7 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client, organizationsUrl return logins, nil } -func (s *SocialGithub) UserInfo(client *http.Client) (*BasicUserInfo, error) { +func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data struct { Id int `json:"id"` diff --git a/pkg/social/google_oauth.go b/pkg/social/google_oauth.go index c44720fbe21..e9ab08305f6 100644 --- a/pkg/social/google_oauth.go +++ b/pkg/social/google_oauth.go @@ -11,7 +11,7 @@ import ( ) type SocialGoogle struct { - *oauth2.Config + *SocialBase allowedDomains []string hostedDomain string apiUrl string @@ -30,7 +30,7 @@ func (s *SocialGoogle) IsSignupAllowed() bool { return s.allowSignup } -func (s *SocialGoogle) UserInfo(client *http.Client) (*BasicUserInfo, error) { +func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data struct { Name string `json:"name"` Email string `json:"email"` diff --git a/pkg/social/grafana_com_oauth.go b/pkg/social/grafana_com_oauth.go index 9cc87d4b41c..d3614520d61 100644 --- a/pkg/social/grafana_com_oauth.go +++ b/pkg/social/grafana_com_oauth.go @@ -11,7 +11,7 @@ import ( ) type SocialGrafanaCom struct { - *oauth2.Config + *SocialBase url string allowedOrganizations []string allowSignup bool @@ -49,7 +49,7 @@ func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool return false } -func (s *SocialGrafanaCom) UserInfo(client *http.Client) (*BasicUserInfo, error) { +func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data struct { Name string `json:"name"` Login string `json:"username"` diff --git a/pkg/social/social.go b/pkg/social/social.go index 8e877ece4f1..b763e2d71b2 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -8,6 +8,7 @@ import ( "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -22,7 +23,7 @@ type BasicUserInfo struct { type SocialConnector interface { Type() int - UserInfo(client *http.Client) (*BasicUserInfo, error) + UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) IsEmailAllowed(email string) bool IsSignupAllowed() bool @@ -31,6 +32,11 @@ type SocialConnector interface { Client(ctx context.Context, t *oauth2.Token) *http.Client } +type SocialBase struct { + *oauth2.Config + log log.Logger +} + type Error struct { s string } @@ -91,10 +97,15 @@ func NewOAuthService() { Scopes: info.Scopes, } + logger := log.New("oauth." + name) + // GitHub. if name == "github" { SocialMap["github"] = &SocialGithub{ - Config: &config, + SocialBase: &SocialBase{ + Config: &config, + log: logger, + }, allowedDomains: info.AllowedDomains, apiUrl: info.ApiUrl, allowSignup: info.AllowSignup, @@ -106,7 +117,10 @@ func NewOAuthService() { // Google. if name == "google" { SocialMap["google"] = &SocialGoogle{ - Config: &config, + SocialBase: &SocialBase{ + Config: &config, + log: logger, + }, allowedDomains: info.AllowedDomains, hostedDomain: info.HostedDomain, apiUrl: info.ApiUrl, @@ -116,8 +130,11 @@ func NewOAuthService() { // Generic - Uses the same scheme as Github. if name == "generic_oauth" { - SocialMap["generic_oauth"] = &GenericOAuth{ - Config: &config, + SocialMap["generic_oauth"] = &SocialGenericOAuth{ + SocialBase: &SocialBase{ + Config: &config, + log: logger, + }, allowedDomains: info.AllowedDomains, apiUrl: info.ApiUrl, allowSignup: info.AllowSignup, @@ -139,7 +156,10 @@ func NewOAuthService() { } SocialMap["grafana_com"] = &SocialGrafanaCom{ - Config: &config, + SocialBase: &SocialBase{ + Config: &config, + log: logger, + }, url: setting.GrafanaComUrl, allowSignup: info.AllowSignup, allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index c6645647be9..251527ab4e5 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -188,18 +188,6 @@ func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryCo data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext) break case "ec2_instance_attribute": - region := parameters.Get("region").MustString() - dsInfo := e.getDsInfo(region) - cfg, err := e.getAwsConfig(dsInfo) - if err != nil { - return nil, fmt.Errorf("Failed to call ec2:DescribeInstances, %v", err) - } - sess, err := session.NewSession(cfg) - if err != nil { - return nil, fmt.Errorf("Failed to call ec2:DescribeInstances, %v", err) - } - e.ec2Svc = ec2.New(sess, cfg) - data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext) break } @@ -227,6 +215,21 @@ func transformToTable(data []suggestData, result *tsdb.QueryResult) { result.Meta.Set("rowCount", len(data)) } +func parseMultiSelectValue(input string) []string { + trimmedInput := strings.TrimSpace(input) + + if strings.HasPrefix(trimmedInput, "{") { + values := strings.Split(strings.TrimRight(strings.TrimLeft(trimmedInput, "{"), "}"), ",") + trimValues := make([]string, len(values)) + for i, v := range values { + trimValues[i] = strings.TrimSpace(v) + } + return trimValues + } else { + return []string{trimmedInput} + } +} + // Whenever this list is updated, frontend list should also be updated. // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { @@ -365,19 +368,44 @@ func (e *CloudWatchExecutor) handleGetDimensionValues(ctx context.Context, param return result, nil } +func (e *CloudWatchExecutor) ensureClientSession(region string) error { + if e.ec2Svc == nil { + dsInfo := e.getDsInfo(region) + cfg, err := e.getAwsConfig(dsInfo) + if err != nil { + return fmt.Errorf("Failed to call ec2:getAwsConfig, %v", err) + } + sess, err := session.NewSession(cfg) + if err != nil { + return fmt.Errorf("Failed to call ec2:NewSession, %v", err) + } + e.ec2Svc = ec2.New(sess, cfg) + } + return nil +} + func (e *CloudWatchExecutor) handleGetEbsVolumeIds(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { region := parameters.Get("region").MustString() instanceId := parameters.Get("instanceId").MustString() - instanceIds := []*string{aws.String(instanceId)} + err := e.ensureClientSession(region) + if err != nil { + return nil, err + } + + instanceIds := aws.StringSlice(parseMultiSelectValue(instanceId)) instances, err := e.ec2DescribeInstances(region, nil, instanceIds) if err != nil { return nil, err } result := make([]suggestData, 0) - for _, mapping := range instances.Reservations[0].Instances[0].BlockDeviceMappings { - result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId}) + for _, reservation := range instances.Reservations { + for _, instance := range reservation.Instances { + for _, mapping := range instance.BlockDeviceMappings { + result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId}) + } + } } return result, nil @@ -404,6 +432,11 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context, } } + err := e.ensureClientSession(region) + if err != nil { + return nil, err + } + instances, err := e.ec2DescribeInstances(region, filters, nil) if err != nil { return nil, err diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 255b343a33a..bf87e7b7d41 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/bmizerany/assert" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" @@ -114,4 +115,85 @@ func TestCloudWatchMetrics(t *testing.T) { So(result[0].Text, ShouldEqual, "i-12345678") }) }) + + Convey("When calling handleGetEbsVolumeIds", t, func() { + + executor := &CloudWatchExecutor{ + ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + { + InstanceId: aws.String("i-1"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-1-1")}}, + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-1-2")}}, + }, + }, + { + InstanceId: aws.String("i-2"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-2-1")}}, + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-2-2")}}, + }, + }, + }, + }, + { + Instances: []*ec2.Instance{ + { + InstanceId: aws.String("i-3"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-3-1")}}, + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-3-2")}}, + }, + }, + { + InstanceId: aws.String("i-4"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-4-1")}}, + {Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-4-2")}}, + }, + }, + }, + }, + }, + }}, + } + + json := simplejson.New() + json.Set("region", "us-east-1") + json.Set("instanceId", "{i-1, i-2, i-3, i-4}") + result, _ := executor.handleGetEbsVolumeIds(context.Background(), json, &tsdb.TsdbQuery{}) + + Convey("Should return all 8 VolumeIds", func() { + So(len(result), ShouldEqual, 8) + So(result[0].Text, ShouldEqual, "vol-1-1") + So(result[1].Text, ShouldEqual, "vol-1-2") + So(result[2].Text, ShouldEqual, "vol-2-1") + So(result[3].Text, ShouldEqual, "vol-2-2") + So(result[4].Text, ShouldEqual, "vol-3-1") + So(result[5].Text, ShouldEqual, "vol-3-2") + So(result[6].Text, ShouldEqual, "vol-4-1") + So(result[7].Text, ShouldEqual, "vol-4-2") + }) + }) +} + +func TestParseMultiSelectValue(t *testing.T) { + + var values []string + + values = parseMultiSelectValue(" i-someInstance ") + assert.Equal(t, []string{"i-someInstance"}, values) + + values = parseMultiSelectValue("{i-05}") + assert.Equal(t, []string{"i-05"}, values) + + values = parseMultiSelectValue(" {i-01, i-03, i-04} ") + assert.Equal(t, []string{"i-01", "i-03", "i-04"}, values) + + values = parseMultiSelectValue("i-{01}") + assert.Equal(t, []string{"i-{01}"}, values) + } diff --git a/pkg/util/shortid_generator.go b/pkg/util/shortid_generator.go new file mode 100644 index 00000000000..067f7c756ba --- /dev/null +++ b/pkg/util/shortid_generator.go @@ -0,0 +1,15 @@ +package util + +import ( + "github.com/teris-io/shortid" +) + +func init() { + gen, _ := shortid.New(1, shortid.DefaultABC, 1) + shortid.SetDefault(gen) +} + +// GenerateShortUid generates a short unique identifier. +func GenerateShortUid() string { + return shortid.MustGenerate() +} diff --git a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx b/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx index a12340555e9..eac18a6c69d 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.jest.tsx @@ -23,7 +23,8 @@ describe('AlertRuleList', () => { .format(), evalData: {}, executionError: '', - dashboardUri: 'db/mygool', + url: 'd/ufkcofof/my-goal', + canEdit: true, }, ]) ); diff --git a/public/app/containers/AlertRuleList/AlertRuleList.tsx b/public/app/containers/AlertRuleList/AlertRuleList.tsx index 429504e46d9..6fb6e3b7d8f 100644 --- a/public/app/containers/AlertRuleList/AlertRuleList.tsx +++ b/public/app/containers/AlertRuleList/AlertRuleList.tsx @@ -137,7 +137,7 @@ export class AlertRuleItem extends React.Component { 'fa-pause': !rule.isPaused, }); - let ruleUrl = `dashboard/${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen&edit&tab=alert`; + let ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`; return (
  • @@ -147,7 +147,8 @@ export class AlertRuleItem extends React.Component {
    - {this.renderText(rule.name)} + {rule.canEdit && {this.renderText(rule.name)}} + {!rule.canEdit && {this.renderText(rule.name)}}
    {this.renderText(rule.stateText)} @@ -156,17 +157,30 @@ export class AlertRuleItem extends React.Component {
    {rule.info &&
    {this.renderText(rule.info)}
    }
    +
    - - - - - + + {rule.canEdit && ( + + + + )} + {!rule.canEdit && ( + + )}
  • ); diff --git a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap index 571566503f8..0914f050a0f 100644 --- a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap +++ b/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap @@ -21,7 +21,7 @@ exports[`AlertRuleList should render 1 rule 1`] = ` className="alert-rule-item__name" > - - + { + constructor(props) { + super(props); + this.handleAddPermission = this.handleAddPermission.bind(this); + this.loadStore(); + } + + componentWillUnmount() { + const { permissions } = this.props; + permissions.hideAddPermissions(); + } + + loadStore() { + const { nav, folder, view } = this.props; + return folder.load(view.routeParams.get('uid') as string).then(res => { + view.updatePathAndQuery(`${res.meta.url}/permissions`, {}, {}); + return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions'); + }); + } + + handleAddPermission() { + const { permissions } = this.props; + permissions.toggleAddPermissions(); + } + + render() { + const { nav, folder, permissions, backendSrv } = this.props; + + if (!folder.folder || !nav.main) { + return

    Loading

    ; + } + + const dashboardId = folder.folder.id; + + return ( +
    + +
    +
    +

    Folder Permissions

    + + + +
    + +
    + + + + +
    +
    + ); + } +} diff --git a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx new file mode 100644 index 00000000000..3630bf534ba --- /dev/null +++ b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { FolderSettings } from './FolderSettings'; +import { RootStore } from 'app/stores/RootStore/RootStore'; +import { backendSrv } from 'test/mocks/common'; +import { shallow } from 'enzyme'; + +describe('FolderSettings', () => { + let wrapper; + let page; + + beforeAll(() => { + backendSrv.getDashboardByUid.mockReturnValue( + Promise.resolve({ + dashboard: { + id: 1, + title: 'Folder Name', + uid: 'uid-str', + }, + meta: { + url: '/dashboards/f/uid/folder-name', + canSave: true, + }, + }) + ); + + const store = RootStore.create( + { + view: { + path: 'asd', + query: {}, + routeParams: { + uid: 'uid-str', + }, + }, + }, + { + backendSrv: backendSrv, + } + ); + + wrapper = shallow(); + page = wrapper.dive(); + return page + .instance() + .loadStore() + .then(() => { + page.update(); + }); + }); + + it('should set the title input field', () => { + const titleInput = page.find('.gf-form-input'); + expect(titleInput).toHaveLength(1); + expect(titleInput.prop('value')).toBe('Folder Name'); + }); + + it('should update title and enable save button when changed', () => { + const titleInput = page.find('.gf-form-input'); + const disabledSubmitButton = page.find('button[type="submit"]'); + expect(disabledSubmitButton.prop('disabled')).toBe(true); + + titleInput.simulate('change', { target: { value: 'New Title' } }); + + const updatedTitleInput = page.find('.gf-form-input'); + expect(updatedTitleInput.prop('value')).toBe('New Title'); + const enabledSubmitButton = page.find('button[type="submit"]'); + expect(enabledSubmitButton.prop('disabled')).toBe(false); + }); + + it('should disable save button if title is changed back to old title', () => { + const titleInput = page.find('.gf-form-input'); + + titleInput.simulate('change', { target: { value: 'Folder Name' } }); + + const enabledSubmitButton = page.find('button[type="submit"]'); + expect(enabledSubmitButton.prop('disabled')).toBe(true); + }); + + it('should disable save button if title is changed to empty string', () => { + const titleInput = page.find('.gf-form-input'); + + titleInput.simulate('change', { target: { value: '' } }); + + const enabledSubmitButton = page.find('button[type="submit"]'); + expect(enabledSubmitButton.prop('disabled')).toBe(true); + }); +}); diff --git a/public/app/containers/ManageDashboards/FolderSettings.tsx b/public/app/containers/ManageDashboards/FolderSettings.tsx new file mode 100644 index 00000000000..a6349764a14 --- /dev/null +++ b/public/app/containers/ManageDashboards/FolderSettings.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { inject, observer } from 'mobx-react'; +import { toJS } from 'mobx'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import IContainerProps from 'app/containers/IContainerProps'; +import { getSnapshot } from 'mobx-state-tree'; +import appEvents from 'app/core/app_events'; + +@inject('nav', 'folder', 'view') +@observer +export class FolderSettings extends React.Component { + formSnapshot: any; + dashboard: any; + + constructor(props) { + super(props); + this.loadStore(); + } + + loadStore() { + const { nav, folder, view } = this.props; + + return folder.load(view.routeParams.get('uid') as string).then(res => { + this.formSnapshot = getSnapshot(folder); + this.dashboard = res.dashboard; + + view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {}); + + return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); + }); + } + + onTitleChange(evt) { + this.props.folder.setTitle(this.getFormSnapshot().folder.title, evt.target.value); + } + + getFormSnapshot() { + if (!this.formSnapshot) { + this.formSnapshot = getSnapshot(this.props.folder); + } + + return this.formSnapshot; + } + + save(evt) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + const { nav, folder, view } = this.props; + + folder + .saveFolder(this.dashboard, { overwrite: false }) + .then(newUrl => { + view.updatePathAndQuery(newUrl, {}, {}); + + appEvents.emit('dashboard-saved'); + appEvents.emit('alert-success', ['Folder saved']); + }) + .then(() => { + return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); + }) + .catch(this.handleSaveFolderError); + } + + delete(evt) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + const { folder, view } = this.props; + const title = folder.folder.title; + + appEvents.emit('confirm-modal', { + title: 'Delete', + text: `Do you want to delete this folder and all its dashboards?`, + icon: 'fa-trash', + yesText: 'Delete', + onConfirm: () => { + return this.props.folder.deleteFolder().then(() => { + appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]); + view.updatePathAndQuery('dashboards', '', ''); + }); + }, + }); + } + + handleSaveFolderError(err) { + if (err.data && err.data.status === 'version-mismatch') { + err.isHandled = true; + + appEvents.emit('confirm-modal', { + title: 'Conflict', + text: 'Someone else has updated this folder.', + text2: 'Would you still like to save this folder?', + yesText: 'Save & Overwrite', + icon: 'fa-warning', + onConfirm: () => { + this.props.folder.saveFolder(this.dashboard, { overwrite: true }); + }, + }); + } + + if (err.data && err.data.status === 'name-exists') { + err.isHandled = true; + + appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']); + } + } + + render() { + const { nav, folder } = this.props; + + if (!folder.folder || !nav.main) { + return

    Loading

    ; + } + + return ( +
    + +
    +

    Folder Settings

    + +
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + ); + } +} diff --git a/public/app/containers/ServerStats/ServerStats.jest.tsx b/public/app/containers/ServerStats/ServerStats.jest.tsx index ae5cb736ad6..a329a47527d 100644 --- a/public/app/containers/ServerStats/ServerStats.jest.tsx +++ b/public/app/containers/ServerStats/ServerStats.jest.tsx @@ -20,7 +20,7 @@ describe('ServerStats', () => { } ); - const page = renderer.create(); + const page = renderer.create(); setTimeout(() => { expect(page.toJSON()).toMatchSnapshot(); diff --git a/public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap b/public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap index 7e6f1d11c27..eac793ca2ca 100644 --- a/public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap +++ b/public/app/containers/ServerStats/__snapshots__/ServerStats.jest.tsx.snap @@ -41,9 +41,9 @@ exports[`ServerStats Should render table with stats 1`] = ` /> - {main.children.map(SelectOption)} + {main.children.map((navItem: NavModelItem) => { + if (navItem.hideFromTabs) { + // TODO: Rename hideFromTabs => hideFromNav + return null; + } + return ( + + ); + })}
    ); -} +}; -function Tabs({ main, customCss }: { main: NavModelItem; customCss: string }) { - return
      {main.children.map(TabItem)}
    ; -} +const Tabs = ({ main, customCss }: { main: NavModelItem; customCss: string }) => { + return ( +
    + ); +}; + +const Navigation = ({ main }: { main: NavModelItem }) => { + return ( + + ); +}; + +@observer export default class PageHeader extends React.Component { constructor(props) { super(props); } - renderBreadcrumb(breadcrumbs) { + shouldComponentUpdate() { + //Hack to re-render on changed props from angular with the @observer decorator + return true; + } + + renderTitle(title: string, breadcrumbs: any[]) { + if (!title && (!breadcrumbs || breadcrumbs.length === 0)) { + return null; + } + + if (!breadcrumbs || breadcrumbs.length === 0) { + return

    {title}

    ; + } + const breadcrumbsResult = []; for (let i = 0; i < breadcrumbs.length; i++) { const bc = breadcrumbs[i]; @@ -99,7 +114,9 @@ export default class PageHeader extends React.Component { breadcrumbsResult.push( / {bc.title}); } } - return breadcrumbsResult; + breadcrumbsResult.push( / {title}); + + return

    {breadcrumbsResult}

    ; } renderHeaderTitle(main) { @@ -111,11 +128,7 @@ export default class PageHeader extends React.Component {
    - {main.text &&

    {main.text}

    } - {main.breadcrumbs && - main.breadcrumbs.length > 0 && ( -

    {this.renderBreadcrumb(main.breadcrumbs)}

    - )} + {this.renderTitle(main.text, main.breadcrumbs)} {main.subTitle &&
    {main.subTitle}
    } {main.subType && (
    @@ -135,12 +148,14 @@ export default class PageHeader extends React.Component { return null; } + const main = toJS(model.main); // Convert to JS if its a mobx observable + return (
    - {this.renderHeaderTitle(model.main)} - {model.main.children && } + {this.renderHeaderTitle(main)} + {main.children && }
    diff --git a/public/app/core/components/Permissions/AddPermissions.jest.tsx b/public/app/core/components/Permissions/AddPermissions.jest.tsx new file mode 100644 index 00000000000..48ff20a16aa --- /dev/null +++ b/public/app/core/components/Permissions/AddPermissions.jest.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import AddPermissions from './AddPermissions'; +import { RootStore } from 'app/stores/RootStore/RootStore'; +import { backendSrv } from 'test/mocks/common'; +import { shallow } from 'enzyme'; + +describe('AddPermissions', () => { + let wrapper; + let store; + let instance; + + beforeAll(() => { + backendSrv.get.mockReturnValue( + Promise.resolve([ + { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' }, + { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' }, + ]) + ); + + backendSrv.post = jest.fn(); + + store = RootStore.create( + {}, + { + backendSrv: backendSrv, + } + ); + + wrapper = shallow(); + instance = wrapper.instance(); + return store.permissions.load(1, true, false); + }); + + describe('when permission for a user is added', () => { + it('should save permission to db', () => { + const evt = { + target: { + value: 'User', + }, + }; + const userItem = { + id: 2, + login: 'user2', + }; + + instance.typeChanged(evt); + instance.userPicked(userItem); + + wrapper.update(); + + expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false); + + wrapper.find('form').simulate('submit', { preventDefault() {} }); + + expect(backendSrv.post.mock.calls.length).toBe(1); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + }); + }); + + describe('when permission for team is added', () => { + it('should save permission to db', () => { + const evt = { + target: { + value: 'Group', + }, + }; + + const teamItem = { + id: 2, + name: 'ug1', + }; + + instance.typeChanged(evt); + instance.teamPicked(teamItem); + + wrapper.update(); + + expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false); + + wrapper.find('form').simulate('submit', { preventDefault() {} }); + + expect(backendSrv.post.mock.calls.length).toBe(1); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + }); + }); + + afterEach(() => { + backendSrv.post.mockClear(); + }); +}); diff --git a/public/app/core/components/Permissions/AddPermissions.tsx b/public/app/core/components/Permissions/AddPermissions.tsx new file mode 100644 index 00000000000..94afa7c1180 --- /dev/null +++ b/public/app/core/components/Permissions/AddPermissions.tsx @@ -0,0 +1,151 @@ +import React, { Component } from 'react'; +import { observer } from 'mobx-react'; +import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore'; +import UserPicker, { User } from 'app/core/components/Picker/UserPicker'; +import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker'; +import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker'; +import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; + +export interface IProps { + permissions: any; + backendSrv: any; +} +@observer +class AddPermissions extends Component { + constructor(props) { + super(props); + this.userPicked = this.userPicked.bind(this); + this.teamPicked = this.teamPicked.bind(this); + this.permissionPicked = this.permissionPicked.bind(this); + this.typeChanged = this.typeChanged.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + componentWillMount() { + const { permissions } = this.props; + permissions.resetNewType(); + } + + typeChanged(evt) { + const { value } = evt.target; + const { permissions } = this.props; + + permissions.setNewType(value); + } + + userPicked(user: User) { + const { permissions } = this.props; + if (!user) { + permissions.newItem.setUser(null, null); + return; + } + return permissions.newItem.setUser(user.id, user.login); + } + + teamPicked(team: Team) { + const { permissions } = this.props; + if (!team) { + permissions.newItem.setTeam(null, null); + return; + } + return permissions.newItem.setTeam(team.id, team.name); + } + + permissionPicked(permission: OptionWithDescription) { + const { permissions } = this.props; + return permissions.newItem.setPermission(permission.value); + } + + resetNewType() { + const { permissions } = this.props; + return permissions.resetNewType(); + } + + handleSubmit(evt) { + evt.preventDefault(); + const { permissions } = this.props; + permissions.addStoreItem(); + } + + render() { + const { permissions, backendSrv } = this.props; + const newItem = permissions.newItem; + const pickerClassName = 'width-20'; + + const isValid = newItem.isValid(); + + return ( +
    + +
    +
    Add Permission For
    +
    +
    +
    + +
    +
    + + {newItem.type === 'User' ? ( +
    + +
    + ) : null} + + {newItem.type === 'Group' ? ( +
    + +
    + ) : null} + +
    + +
    + +
    + +
    +
    +
    + {permissions.error ? ( +
    + + + {permissions.error} + +
    + ) : null} +
    + ); + } +} + +export default AddPermissions; diff --git a/public/app/core/components/Permissions/DashboardPermissions.tsx b/public/app/core/components/Permissions/DashboardPermissions.tsx new file mode 100644 index 00000000000..12339cc7c34 --- /dev/null +++ b/public/app/core/components/Permissions/DashboardPermissions.tsx @@ -0,0 +1,70 @@ +import React, { Component } from 'react'; +import { observer } from 'mobx-react'; +import { store } from 'app/stores/store'; +import Permissions from 'app/core/components/Permissions/Permissions'; +import Tooltip from 'app/core/components/Tooltip/Tooltip'; +import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo'; +import AddPermissions from 'app/core/components/Permissions/AddPermissions'; +import SlideDown from 'app/core/components/Animations/SlideDown'; +import { FolderInfo } from './FolderInfo'; + +export interface IProps { + dashboardId: number; + folder?: FolderInfo; + backendSrv: any; +} +@observer +class DashboardPermissions extends Component { + permissions: any; + + constructor(props) { + super(props); + this.handleAddPermission = this.handleAddPermission.bind(this); + this.permissions = store.permissions; + } + + handleAddPermission() { + this.permissions.toggleAddPermissions(); + } + + componentWillUnmount() { + this.permissions.hideAddPermissions(); + } + + render() { + const { dashboardId, folder, backendSrv } = this.props; + + return ( +
    +
    +
    +

    Permissions

    + + + +
    + +
    +
    + + + + +
    + ); + } +} + +export default DashboardPermissions; diff --git a/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx b/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx new file mode 100644 index 00000000000..db45714136e --- /dev/null +++ b/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx @@ -0,0 +1,40 @@ +import React, { Component } from 'react'; +import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; +import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; + +export interface IProps { + item: any; +} + +export default class DisabledPermissionListItem extends Component { + render() { + const { item } = this.props; + + return ( + + + + + + + Can + +
    + {}} + value={item.permission} + disabled={true} + className={'gf-form-input--form-dropdown-right'} + /> +
    + + + + + + ); + } +} diff --git a/public/app/core/components/Permissions/FolderInfo.ts b/public/app/core/components/Permissions/FolderInfo.ts new file mode 100644 index 00000000000..d4a6020bb71 --- /dev/null +++ b/public/app/core/components/Permissions/FolderInfo.ts @@ -0,0 +1,5 @@ +export interface FolderInfo { + id: number; + title: string; + url: string; +} diff --git a/public/app/core/components/Permissions/Permissions.tsx b/public/app/core/components/Permissions/Permissions.tsx new file mode 100644 index 00000000000..0a0572ed86e --- /dev/null +++ b/public/app/core/components/Permissions/Permissions.tsx @@ -0,0 +1,92 @@ +import React, { Component } from 'react'; +import PermissionsList from './PermissionsList'; +import { observer } from 'mobx-react'; +import { FolderInfo } from './FolderInfo'; + +export interface DashboardAcl { + id?: number; + dashboardId?: number; + userId?: number; + userLogin?: string; + userEmail?: string; + teamId?: number; + team?: string; + permission?: number; + permissionName?: string; + role?: string; + icon?: string; + nameHtml?: string; + inherited?: boolean; + sortName?: string; + sortRank?: number; +} + +export interface IProps { + dashboardId: number; + folderInfo?: FolderInfo; + permissions?: any; + isFolder: boolean; + backendSrv: any; +} + +@observer +class Permissions extends Component { + constructor(props) { + super(props); + const { dashboardId, isFolder, folderInfo } = this.props; + this.permissionChanged = this.permissionChanged.bind(this); + this.typeChanged = this.typeChanged.bind(this); + this.removeItem = this.removeItem.bind(this); + this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0); + } + + loadStore(dashboardId, isFolder, isInRoot = false) { + return this.props.permissions.load(dashboardId, isFolder, isInRoot); + } + + permissionChanged(index: number, permission: number, permissionName: string) { + const { permissions } = this.props; + permissions.updatePermissionOnIndex(index, permission, permissionName); + } + + removeItem(index: number) { + const { permissions } = this.props; + permissions.removeStoreItem(index); + } + + resetNewType() { + const { permissions } = this.props; + permissions.resetNewType(); + } + + typeChanged(evt) { + const { value } = evt.target; + const { permissions, dashboardId } = this.props; + + if (value === 'Viewer' || value === 'Editor') { + permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId); + this.resetNewType(); + return; + } + + permissions.setNewType(value); + } + + render() { + const { permissions, folderInfo } = this.props; + + return ( +
    + +
    + ); + } +} + +export default Permissions; diff --git a/public/app/core/components/Permissions/PermissionsInfo.tsx b/public/app/core/components/Permissions/PermissionsInfo.tsx new file mode 100644 index 00000000000..9791e344085 --- /dev/null +++ b/public/app/core/components/Permissions/PermissionsInfo.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export default () => { + return ( +
    +
    What are Permissions?
    +

    + An Access Control List (ACL) model is used to limit access to Dashboard Folders. A user or a Team can be + assigned permissions for a folder or for a single dashboard. +

    +
    + ); +}; diff --git a/public/app/core/components/Permissions/PermissionsList.tsx b/public/app/core/components/Permissions/PermissionsList.tsx new file mode 100644 index 00000000000..b215dad2391 --- /dev/null +++ b/public/app/core/components/Permissions/PermissionsList.tsx @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import PermissionsListItem from './PermissionsListItem'; +import DisabledPermissionsListItem from './DisabledPermissionsListItem'; +import { observer } from 'mobx-react'; +import { FolderInfo } from './FolderInfo'; + +export interface IProps { + permissions: any[]; + removeItem: any; + permissionChanged: any; + fetching: boolean; + folderInfo?: FolderInfo; +} + +@observer +class PermissionsList extends Component { + render() { + const { permissions, removeItem, permissionChanged, fetching, folderInfo } = this.props; + + return ( + + + Admin Role', + permission: 4, + icon: 'fa fa-fw fa-street-view', + }} + /> + {permissions.map((item, idx) => { + return ( + + ); + })} + {fetching === true && permissions.length < 1 ? ( + + + + ) : null} + + {fetching === false && permissions.length < 1 ? ( + + + + ) : null} + +
    + Loading permissions... +
    + No permissions are set. Will only be accessible by admins. +
    + ); + } +} + +export default PermissionsList; diff --git a/public/app/core/components/Permissions/PermissionsListItem.tsx b/public/app/core/components/Permissions/PermissionsListItem.tsx new file mode 100644 index 00000000000..3140b8fcc0c --- /dev/null +++ b/public/app/core/components/Permissions/PermissionsListItem.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { observer } from 'mobx-react'; +import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; +import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; + +const setClassNameHelper = inherited => { + return inherited ? 'gf-form-disabled' : ''; +}; + +export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => { + const handleRemoveItem = evt => { + evt.preventDefault(); + removeItem(itemIndex); + }; + + const handleChangePermission = permissionOption => { + permissionChanged(itemIndex, permissionOption.value, permissionOption.label); + }; + + const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0; + + return ( + + + + + + + {item.inherited && + folderInfo && ( + + Inherited from folder{' '} + + {folderInfo.title} + {' '} + + )} + {inheritedFromRoot && Default Permission} + + Can + +
    + +
    + + + {!item.inherited ? ( + + + + ) : ( + + )} + + + ); +}); diff --git a/public/app/core/components/Picker/DescriptionOption.tsx b/public/app/core/components/Picker/DescriptionOption.tsx new file mode 100644 index 00000000000..12a1fdd9163 --- /dev/null +++ b/public/app/core/components/Picker/DescriptionOption.tsx @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; + +export interface IProps { + onSelect: any; + onFocus: any; + option: any; + isFocused: any; + className: any; +} + +class DescriptionOption extends Component { + constructor(props) { + super(props); + this.handleMouseDown = this.handleMouseDown.bind(this); + this.handleMouseEnter = this.handleMouseEnter.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + } + + handleMouseDown(event) { + event.preventDefault(); + event.stopPropagation(); + this.props.onSelect(this.props.option, event); + } + + handleMouseEnter(event) { + this.props.onFocus(this.props.option, event); + } + + handleMouseMove(event) { + if (this.props.isFocused) { + return; + } + this.props.onFocus(this.props.option, event); + } + + render() { + const { option, children, className } = this.props; + return ( +