azure-blob 0.4.2 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7eab2543de0e2e4663211095fd459507761bf270038308464723270506f63ac5
4
- data.tar.gz: 1c0b7016bf21df9c1134be50eb2555f14f9f4b76e9a8664e70e003cee5b04208
3
+ metadata.gz: 94edcaaaa7717925c1fb8238b9e7420949b26c98c90344cbf54930d5e0ff3b19
4
+ data.tar.gz: aae153da753212659590ed4a3ae029c86cf7ed66b4f924dac1a5f3a5ff3b2e05
5
5
  SHA512:
6
- metadata.gz: 6f03e61001c0c41ed31661116d5f7ed5d5f95f91e8ded26d40aa72d446119e835c281d852904e00b02f68fd536d0451e4e71401410ab41ec68f7ff97fb030a88
7
- data.tar.gz: b0b1beb93ad7b3950bddb41dfb6e04354e08b46e29f5f3db5d474ddd4964cd09d65776a1f57625d103d1e56e9a0d235579372c74a867fa2080beabcfc757395f
6
+ metadata.gz: 102c3d5fd08761199413e96bd8e4bae8a84ac5478d7bb1202e232fbff346703f7cb3b8f344553983b607dbacec56942452ce18aa0afc184a4c994d31dc94118c
7
+ data.tar.gz: 646636874dd381757595f82999dd0b7b5647a740512c8e2baf86bdc2045e579cdf78354a4690c746452280efb7ff92cbf9c49c95775ccbcf6a690eb0ae05131f
@@ -0,0 +1,22 @@
1
+ # This file is maintained automatically by "terraform init".
2
+ # Manual edits may be lost in future updates.
3
+
4
+ provider "registry.terraform.io/hashicorp/azurerm" {
5
+ version = "3.113.0"
6
+ constraints = "~> 3.0"
7
+ hashes = [
8
+ "h1:eEUtt0lrLdpVaF6FiDq8BGQPgEcykmhj0aNIL7hTOGw=",
9
+ "zh:12479f5664288943400447b55e50df675c28ae82ad8d373cc2e5682f3a3411f0",
10
+ "zh:1b42a14e80e568429d3b55fed753ca3ef0df9dcdfa107890d7264599c020940f",
11
+ "zh:381be6ca617f848de3baa3985a6e1788e91a803afe04a3c5c727453528b6310d",
12
+ "zh:3e70e2e07b6db1c363de3e5d0ca47f27fc956473df03329c7d2e54d3ac29176b",
13
+ "zh:87c7633aeaa828098c6055da9e67d4acaf4b46748b6b3f0267e105e55f05de25",
14
+ "zh:8d0d98226901f874770dd5220d4701a12ae8bd586994615aa7dcba12b9736bec",
15
+ "zh:9fd913acd42a60c3a90a18ce803567ef861db8779a59aacced91f2cbd86de9d9",
16
+ "zh:b6f3f7ae0a055437fb36c139af9bb3135e7f4dad172157ae1eb0177dc74d703f",
17
+ "zh:b927027ba2bf40d34e03d742fd2b6c5299023b5ab8e6f05e50aac76a46ad1094",
18
+ "zh:ceb5187b9d2a439f4e48944f3ffeeeaf47a03dbe6f3325ea1775bf659ce0aa88",
19
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
20
+ "zh:fb9d78dfeca7489bffca9b1a1f3abee7f16dbbcba31388aea1102062c1d6dce8",
21
+ ]
22
+ }
data/CHANGELOG.md CHANGED
@@ -1,8 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] 2024-09-09
4
+
5
+ - Added support for Managed Identities (Entra ID)
6
+
3
7
  ## [0.4.2] 2024-06-06
4
8
 
5
- Documentation
9
+ - Documentation
10
+ - Fix an issue with integrity check on multi block upload
11
+
6
12
 
7
13
  ## [0.4.1] 2024-05-27
8
14
 
data/README.md CHANGED
@@ -4,7 +4,7 @@ This gem was built to replace azure-storage-blob (deprecated) in Active Storage,
4
4
 
5
5
  ## Active Storage
6
6
 
7
- ## Migration
7
+ ### Migration
8
8
  To migrate from azure-storage-blob to azure-blob:
9
9
 
10
10
  1. Replace `azure-storage-blob` in your Gemfile with `azure-blob`
@@ -12,6 +12,29 @@ To migrate from azure-storage-blob to azure-blob:
12
12
  3. Change the `AzureStorage` service to `AzureBlob` in your Active Storage config (`config/storage.yml`)
13
13
  4. Restart or deploy the app.
14
14
 
15
+ ### Managed Identity (Entra ID)
16
+
17
+ AzureBlob supports managed identities on :
18
+ - Azure VM
19
+ - App Service
20
+ - Azure Functions (Untested but should work)
21
+ - Azure Containers (Untested but should work)
22
+
23
+ AKS support will likely require more work. Contributions are welcome.
24
+
25
+ To authenticate through managed identities instead of a shared key, omit `storage_access_key` from your `storage.yml` file.
26
+
27
+ It is recommended to add the identity's `principal_id` to the config.
28
+
29
+ ActiveStorage config example:
30
+
31
+ ```
32
+ prod:
33
+ service: AzureBlob
34
+ container: container_name
35
+ storage_account_name: account_name
36
+ principal_id: 71b34410-4c50-451d-b456-95ead1b18cce
37
+ ```
15
38
 
16
39
  ## Standalone
17
40
 
@@ -42,6 +65,42 @@ For the full list of methods: https://www.rubydoc.info/gems/azure-blob/AzureBlob
42
65
 
43
66
  ### Dev environment
44
67
 
68
+ A dev environment is supplied through Nix with [devenv](https://devenv.sh/).
69
+
70
+ 1. Install [devenv](https://devenv.sh/).
71
+ 2. Enter the dev environment by cd into the repo and running `devenv shell` (or `direnv allow` if you are a direnv user).
72
+ 3. Log into azure CLI with `az login`
73
+ 4. `terraform init`
74
+ 5. `terraform apply` This will generate the necessary infrastructure on azure.
75
+ 6. Generate devenv.local.nix with your private key and container information: `generate-env-file`
76
+ 7. If you are using direnv, the environment will reload automatically. If not, exit the shell and reopen it by hitting <C-d> and running `devenv shell` again.
77
+
78
+ #### Entra ID
79
+
80
+ To test with Entra ID, the `AZURE_ACCESS_KEY` environment variable must be unset and the code must be ran or proxied through a VPS with the proper roles.
81
+
82
+ For cost saving, the terraform variable `create_vm` is false by default.
83
+ To create the VPS, Create a var file `var.tfvars` containing:
84
+
85
+ ```
86
+ create_vm = true
87
+ ```
88
+ and re-apply terraform: `terraform apply -var-file=var.tfvars`.
89
+
90
+ This will create the VPS and required managed identities.
91
+
92
+ `bin/rake test_azure_vm` and `bin/rake test_app_service` will establish a VPN connection to the VM or App service container and run the test suite. You might be prompted for a sudo password when the VPN starts (sshuttle).
93
+
94
+ After you are done, run terraform again without the var file (`terraform apply`) to destroy the VPS and App service application.
95
+
96
+ #### Cleanup
97
+
98
+ Some tests copied over from Rails don't clean after themselves. A rake task is provided to empty your containers and keep cost low: `bin/rake flush_test_container`
99
+
100
+ #### Run without devenv/nix
101
+
102
+ If you prefer not using devenv/nix:
103
+
45
104
  Ensure your version of Ruby fit the minimum version in `azure-blob.gemspec`
46
105
 
47
106
  and setup those Env variables:
@@ -51,19 +110,6 @@ and setup those Env variables:
51
110
  - `AZURE_PRIVATE_CONTAINER`
52
111
  - `AZURE_PUBLIC_CONTAINER`
53
112
 
54
-
55
- A dev environment setup is also supplied through Nix with [devenv](https://devenv.sh/).
56
-
57
- To use the Nix environment:
58
- 1. install [devenv](https://devenv.sh/)
59
- 2. Copy `devenv.local.nix.example` to `devenv.local.nix`
60
- 3. Insert your azure credentials into `devenv.local.nix`
61
- 4. Start the shell with `devenv shell` or with [direnv](https://direnv.net/).
62
-
63
- ### Tests
64
-
65
- `bin/rake test`
66
-
67
113
  ## License
68
114
 
69
115
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -2,21 +2,57 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
- require 'azure_blob'
5
+ require "azure_blob"
6
+ require_relative "test/support/app_service_vpn"
7
+ require_relative "test/support/azure_vm_vpn"
6
8
 
7
- Minitest::TestTask.create
9
+ Minitest::TestTask.create(:test_rails) do
10
+ self.test_globs = [ "test/rails/**/test_*.rb",
11
+ "test/rails/**/*_test.rb", ]
12
+ end
13
+
14
+ Minitest::TestTask.create(:test_client) do
15
+ self.test_globs = [ "test/client/**/test_*.rb",
16
+ "test/client/**/*_test.rb", ]
17
+ end
8
18
 
9
19
  task default: %i[test]
10
20
 
21
+ task :test do
22
+ Rake::Task["test_client"].execute
23
+ Rake::Task["test_rails"].execute
24
+ end
25
+
26
+ task :test_app_service do |t|
27
+ vpn = AppServiceVpn.new
28
+ ENV["IDENTITY_ENDPOINT"] = vpn.endpoint
29
+ ENV["IDENTITY_HEADER"] = vpn.header
30
+ Rake::Task["test_entra_id"].execute
31
+ ensure
32
+ vpn.kill
33
+ end
34
+
35
+ task :test_azure_vm do |t|
36
+ vpn = AzureVmVpn.new
37
+ Rake::Task["test_entra_id"].execute
38
+ ensure
39
+ vpn.kill
40
+ end
41
+
42
+ task :test_entra_id do |t|
43
+ ENV["AZURE_ACCESS_KEY"] = nil
44
+ Rake::Task["test"].execute
45
+ end
46
+
11
47
  task :flush_test_container do |t|
12
48
  AzureBlob::Client.new(
13
49
  account_name: ENV["AZURE_ACCOUNT_NAME"],
14
50
  access_key: ENV["AZURE_ACCESS_KEY"],
15
51
  container: ENV["AZURE_PRIVATE_CONTAINER"],
16
- ).delete_prefix ''
52
+ ).delete_prefix ""
17
53
  AzureBlob::Client.new(
18
54
  account_name: ENV["AZURE_ACCOUNT_NAME"],
19
55
  access_key: ENV["AZURE_ACCESS_KEY"],
20
56
  container: ENV["AZURE_PUBLIC_CONTAINER"],
21
- ).delete_prefix ''
57
+ ).delete_prefix ""
22
58
  end
data/azure-blob.gemspec CHANGED
@@ -13,6 +13,7 @@ Gem::Specification.new do |spec|
13
13
  spec.license = "MIT"
14
14
  spec.required_ruby_version = ">= 3.1"
15
15
 
16
+ spec.metadata["rubygems_mfa_required"] = "true"
16
17
  spec.metadata["homepage_uri"] = spec.homepage
17
18
  spec.metadata["source_code_uri"] = spec.homepage
18
19
  spec.metadata["changelog_uri"] = "https://github.com/testdouble/azure-blob/blob/main/CHANGELOG.md"
data/devenv.lock CHANGED
@@ -108,16 +108,16 @@
108
108
  },
109
109
  "nixpkgs": {
110
110
  "locked": {
111
- "lastModified": 1715542476,
111
+ "lastModified": 1725001927,
112
112
  "owner": "NixOS",
113
113
  "repo": "nixpkgs",
114
- "rev": "44072e24566c5bcc0b7aa9178a0104f4cfffab19",
115
- "treeHash": "3f9021e4c33de6fe59b88ac8c3019fc49136dc2a",
114
+ "rev": "6e99f2a27d600612004fbd2c3282d614bfee6421",
115
+ "treeHash": "1e85443cc9f0ba302df2cf61cacb8014943e2d19",
116
116
  "type": "github"
117
117
  },
118
118
  "original": {
119
119
  "owner": "NixOS",
120
- "ref": "nixos-23.11",
120
+ "ref": "nixos-24.05",
121
121
  "repo": "nixpkgs",
122
122
  "type": "github"
123
123
  }
@@ -129,11 +129,11 @@
129
129
  "nixpkgs": "nixpkgs_2"
130
130
  },
131
131
  "locked": {
132
- "lastModified": 1713939467,
132
+ "lastModified": 1724737223,
133
133
  "owner": "bobvanderlinden",
134
134
  "repo": "nixpkgs-ruby",
135
- "rev": "c1ba161adf31119cfdbb24489766a7bcd4dbe881",
136
- "treeHash": "0d32620317b29f94d6718684f030dd2fc2f30cb2",
135
+ "rev": "175b5867babcbc471b94be9fd5576f2973bbdb6d",
136
+ "treeHash": "2fe3404ac0eeb7bcb7ac7b5f5f8b9b6a7e460147",
137
137
  "type": "github"
138
138
  },
139
139
  "original": {
@@ -160,16 +160,16 @@
160
160
  },
161
161
  "nixpkgs_2": {
162
162
  "locked": {
163
- "lastModified": 1715542476,
163
+ "lastModified": 1725001927,
164
164
  "owner": "NixOS",
165
165
  "repo": "nixpkgs",
166
- "rev": "44072e24566c5bcc0b7aa9178a0104f4cfffab19",
167
- "treeHash": "3f9021e4c33de6fe59b88ac8c3019fc49136dc2a",
166
+ "rev": "6e99f2a27d600612004fbd2c3282d614bfee6421",
167
+ "treeHash": "1e85443cc9f0ba302df2cf61cacb8014943e2d19",
168
168
  "type": "github"
169
169
  },
170
170
  "original": {
171
171
  "owner": "NixOS",
172
- "ref": "nixos-23.11",
172
+ "ref": "nixos-24.05",
173
173
  "repo": "nixpkgs",
174
174
  "type": "github"
175
175
  }
data/devenv.nix CHANGED
@@ -1,12 +1,36 @@
1
- { pkgs, ... }:
1
+ { pkgs, config, ... }:
2
2
 
3
3
  {
4
+ env = {
5
+ LD_LIBRARY_PATH = "${config.devenv.profile}/lib";
6
+ };
7
+
4
8
  packages = with pkgs; [
5
9
  git
6
10
  libyaml
11
+ terraform
12
+ azure-cli
13
+ glib
14
+ vips
15
+ sshuttle
16
+ sshpass
17
+ rsync
7
18
  ];
8
19
 
9
-
10
20
  languages.ruby.enable = true;
11
- languages.ruby.version = "3.1.5";
21
+ languages.ruby.version = "3.1.6";
22
+
23
+ scripts.generate-env-file.exec = ''
24
+ terraform output -raw devenv_local_nix > devenv.local.nix
25
+ '';
26
+
27
+ scripts.proxy-vps.exec = ''
28
+ exec sshuttle -e "ssh -o CheckHostIP=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" -r "$(terraform output --raw vm_username)@$(terraform output --raw vm_ip)" 0/0
29
+ '';
30
+
31
+ scripts.start-app-service-ssh.exec = ''
32
+ resource_group=$(terraform output --raw "resource_group")
33
+ app_name=$(terraform output --raw "app_service_app_name")
34
+ exec az webapp create-remote-connection --resource-group $resource_group --name $app_name
35
+ '';
12
36
  }
data/devenv.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  allowUnfree: true
2
2
  inputs:
3
3
  nixpkgs:
4
- url: github:NixOS/nixpkgs/nixos-23.11
4
+ url: github:NixOS/nixpkgs/nixos-24.05
5
5
  nixpkgs-ruby:
6
6
  url: github:bobvanderlinden/nixpkgs-ruby
data/input.tf ADDED
@@ -0,0 +1,44 @@
1
+ variable "location" {
2
+ type = string
3
+ default = "westus2"
4
+ }
5
+
6
+ variable "prefix" {
7
+ type = string
8
+ default = "azure-blob"
9
+ }
10
+
11
+ variable "storage_account_name" {
12
+ type = string
13
+ default = "azureblobrubygemdev"
14
+ }
15
+
16
+ variable "create_vm" {
17
+ type = bool
18
+ default = false
19
+ }
20
+
21
+ variable "vm_size" {
22
+ type = string
23
+ default = "Standard_B2s"
24
+ }
25
+
26
+ variable "vm_username" {
27
+ type = string
28
+ default = "azureblob"
29
+ }
30
+
31
+ variable "vm_password" {
32
+ type = string
33
+ default = "qwe123QWE!@#"
34
+ }
35
+
36
+ variable "create_app_service" {
37
+ type = bool
38
+ default = false
39
+ }
40
+
41
+ variable "ssh_key" {
42
+ type = string
43
+ default = ""
44
+ }
@@ -35,7 +35,7 @@ module ActiveStorage
35
35
  class Service::AzureBlobService < Service
36
36
  attr_reader :client, :container, :signer
37
37
 
38
- def initialize(storage_account_name:, storage_access_key:, container:, public: false, **options)
38
+ def initialize(storage_account_name:, storage_access_key: nil, container:, public: false, **options)
39
39
  @container = container
40
40
  @public = public
41
41
  @client = AzureBlob::Client.new(
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "signer"
4
3
  require_relative "block_list"
5
4
  require_relative "blob_list"
6
5
  require_relative "blob"
7
6
  require_relative "http"
7
+ require_relative "shared_key_signer"
8
+ require_relative "entra_id_signer"
8
9
  require "time"
9
10
  require "base64"
10
11
 
@@ -12,10 +13,13 @@ module AzureBlob
12
13
  # AzureBlob Client class. You interact with the Azure Blob api
13
14
  # through an instance of this class.
14
15
  class Client
15
- def initialize(account_name:, access_key:, container:)
16
+ def initialize(account_name:, access_key:, container:, **options)
16
17
  @account_name = account_name
17
18
  @container = container
18
- @signer = Signer.new(account_name:, access_key:)
19
+
20
+ @signer = !access_key.nil? && !access_key.empty? ?
21
+ AzureBlob::SharedKeySigner.new(account_name:, access_key:) :
22
+ AzureBlob::EntraIdSigner.new(account_name:, **options.slice(:principal_id))
19
23
  end
20
24
 
21
25
  # Create a blob of type block. Will automatically split the the blob in multiple block and send the blob in pieces (blocks) if the blob is too big.
@@ -0,0 +1,115 @@
1
+ require "base64"
2
+ require "openssl"
3
+ require "net/http"
4
+ require "rexml/document"
5
+
6
+ require_relative "canonicalized_resource"
7
+ require_relative "identity_token"
8
+
9
+ require_relative "user_delegation_key"
10
+
11
+ module AzureBlob
12
+ class EntraIdSigner # :nodoc:
13
+ attr_reader :token
14
+ attr_reader :account_name
15
+
16
+ def initialize(account_name:, principal_id: nil)
17
+ @token = AzureBlob::IdentityToken.new(principal_id:)
18
+ @account_name = account_name
19
+ end
20
+
21
+ def authorization_header(uri:, verb:, headers: {})
22
+ "Bearer #{token}"
23
+ end
24
+
25
+ def sas_token(uri, options = {})
26
+ to_sign = [
27
+ options[:permissions],
28
+ options[:start],
29
+ options[:expiry],
30
+ CanonicalizedResource.new(uri, account_name, url_safe: false, service_name: :blob),
31
+ delegation_key.signed_oid,
32
+ delegation_key.signed_tid,
33
+ delegation_key.signed_start,
34
+ delegation_key.signed_expiry,
35
+ delegation_key.signed_service,
36
+ delegation_key.signed_version,
37
+ nil,
38
+ nil,
39
+ nil,
40
+ options[:ip],
41
+ options[:protocol],
42
+ SAS::Version,
43
+ SAS::Resources::Blob,
44
+ nil,
45
+ nil,
46
+ nil,
47
+ options[:content_disposition],
48
+ nil,
49
+ nil,
50
+ options[:content_type],
51
+ ].join("\n")
52
+
53
+ query = {
54
+ SAS::Fields::Permissions => options[:permissions],
55
+ SAS::Fields::Start => options[:start],
56
+ SAS::Fields::Expiry => options[:expiry],
57
+
58
+ SAS::Fields::SignedObjectId => delegation_key.signed_oid,
59
+ SAS::Fields::SignedTenantId => delegation_key.signed_tid,
60
+ SAS::Fields::SignedKeyStartTime => delegation_key.signed_start,
61
+ SAS::Fields::SignedKeyExpiryTime => delegation_key.signed_expiry,
62
+ SAS::Fields::SignedKeyService => delegation_key.signed_service,
63
+ SAS::Fields::Signedkeyversion => delegation_key.signed_version,
64
+
65
+
66
+ SAS::Fields::SignedIp => options[:ip],
67
+ SAS::Fields::SignedProtocol => options[:protocol],
68
+ SAS::Fields::Version => SAS::Version,
69
+ SAS::Fields::Resource => SAS::Resources::Blob,
70
+
71
+ SAS::Fields::Disposition => options[:content_disposition],
72
+ SAS::Fields::Type => options[:content_type],
73
+ SAS::Fields::Signature => sign(to_sign, key: delegation_key.to_s),
74
+
75
+ }.reject { |_, value| value.nil? }
76
+
77
+ URI.encode_www_form(**query)
78
+ end
79
+
80
+ private
81
+
82
+ def delegation_key
83
+ @delegation_key ||= UserDelegationKey.new(account_name:, signer: self)
84
+ end
85
+
86
+ def sign(body, key:)
87
+ Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", key, body))
88
+ end
89
+
90
+ module SAS # :nodoc:
91
+ Version = "2024-05-04"
92
+ module Fields # :nodoc:
93
+ Permissions = :sp
94
+ Version = :sv
95
+ Start = :st
96
+ Expiry = :se
97
+ Resource = :sr
98
+ Signature = :sig
99
+ Disposition = :rscd
100
+ Type = :rsct
101
+ SignedObjectId = :skoid
102
+ SignedTenantId = :sktid
103
+ SignedKeyStartTime = :skt
104
+ SignedKeyExpiryTime = :ske
105
+ SignedKeyService = :sks
106
+ Signedkeyversion = :skv
107
+ SignedIp = :sip
108
+ SignedProtocol = :spr
109
+ end
110
+ module Resources # :nodoc:
111
+ Blob = :b
112
+ end
113
+ end
114
+ end
115
+ end
@@ -7,7 +7,14 @@ require "rexml"
7
7
 
8
8
  module AzureBlob
9
9
  class Http # :nodoc:
10
- class Error < AzureBlob::Error; end
10
+ class Error < AzureBlob::Error
11
+ attr_reader :body, :status
12
+ def initialize(body: nil, status: nil)
13
+ @body = body
14
+ @status = status
15
+ super(body)
16
+ end
17
+ end
11
18
  class FileNotFoundError < Error; end
12
19
  class ForbidenError < Error; end
13
20
  class IntegrityError < Error; end
@@ -44,6 +51,15 @@ module AzureBlob
44
51
  true
45
52
  end
46
53
 
54
+ def post(content)
55
+ sign_request("POST") if signer
56
+ @response = http.start do |http|
57
+ http.post(uri, content, headers)
58
+ end
59
+ raise_error unless success?
60
+ response.body
61
+ end
62
+
47
63
  def head
48
64
  sign_request("HEAD") if signer
49
65
  @response = http.start do |http|
@@ -91,7 +107,7 @@ module AzureBlob
91
107
  end
92
108
 
93
109
  def raise_error
94
- raise error_from_response.new(@response.body)
110
+ raise error_from_response.new(body: @response.body, status: @response.code&.to_i)
95
111
  end
96
112
 
97
113
  def status
@@ -0,0 +1,65 @@
1
+ require "json"
2
+
3
+ module AzureBlob
4
+ class IdentityToken
5
+ RESOURCE_URI = "https://storage.azure.com/"
6
+ EXPIRATION_BUFFER = 600 # 10 minutes
7
+
8
+ IDENTITY_ENDPOINT = ENV["IDENTITY_ENDPOINT"] || "http://169.254.169.254/metadata/identity/oauth2/token"
9
+ API_VERSION = ENV["IDENTITY_ENDPOINT"] ? "2019-08-01" : "2018-02-01"
10
+
11
+ def initialize(principal_id: nil)
12
+ @identity_uri = URI.parse(IDENTITY_ENDPOINT)
13
+ params = {
14
+ 'api-version': API_VERSION,
15
+ resource: RESOURCE_URI,
16
+ }
17
+ params[:principal_id] = principal_id if principal_id
18
+ @identity_uri.query = URI.encode_www_form(params)
19
+ end
20
+
21
+ def to_s
22
+ refresh if expired?
23
+ token
24
+ end
25
+
26
+ private
27
+
28
+ def expired?
29
+ token.nil? || Time.now >= (expiration - EXPIRATION_BUFFER)
30
+ end
31
+
32
+ def refresh
33
+ headers = { "Metadata" => "true" }
34
+ headers["X-IDENTITY-HEADER"] = ENV["IDENTITY_HEADER"] if ENV["IDENTITY_HEADER"]
35
+
36
+ attempt = 0
37
+ begin
38
+ attempt += 1
39
+ response = JSON.parse(AzureBlob::Http.new(identity_uri, headers).get)
40
+ rescue AzureBlob::Http::Error => error
41
+ if should_retry?(error, attempt)
42
+ attempt = 1 if error.status == 410
43
+ delay = exponential_backoff(error, attempt)
44
+ Kernel.sleep(delay)
45
+ retry
46
+ end
47
+ raise
48
+ end
49
+ @token = response["access_token"]
50
+ @expiration = Time.at(response["expires_on"].to_i)
51
+ end
52
+
53
+ def should_retry?(error, attempt)
54
+ is_500 = error.status/500 == 1
55
+ (is_500 || [ 404, 408, 410, 429 ].include?(error.status)) && attempt < 5
56
+ end
57
+
58
+ def exponential_backoff(error, attempt)
59
+ EXPONENTIAL_BACKOFF[attempt -1] || raise(AzureBlob::Error.new("Exponential backoff out of bounds!"))
60
+ end
61
+ EXPONENTIAL_BACKOFF = [ 2, 6, 14, 30 ]
62
+
63
+ attr_reader :identity_uri, :expiration, :token
64
+ end
65
+ end
@@ -6,7 +6,7 @@ require_relative "canonicalized_headers"
6
6
  require_relative "canonicalized_resource"
7
7
 
8
8
  module AzureBlob
9
- class Signer # :nodoc:
9
+ class SharedKeySigner # :nodoc:
10
10
  def initialize(account_name:, access_key:)
11
11
  @account_name = account_name
12
12
  @access_key = Base64.decode64(access_key)
@@ -71,12 +71,12 @@ module AzureBlob
71
71
  URI.encode_www_form(**query)
72
72
  end
73
73
 
74
+ private
75
+
74
76
  def sign(body)
75
77
  Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", access_key, body))
76
78
  end
77
79
 
78
- private
79
-
80
80
  def sanitize_headers(headers)
81
81
  headers = headers.dup
82
82
  headers[:"Content-Length"] = nil if headers[:"Content-Length"].to_i == 0
@@ -0,0 +1,67 @@
1
+ require_relative "http"
2
+
3
+ module AzureBlob
4
+ class UserDelegationKey # :nodoc:
5
+ EXPIRATION = 25200 # 7 hours
6
+ EXPIRATION_BUFFER = 3600 # 1 hours
7
+ def initialize(account_name:, signer:)
8
+ @uri = URI.parse(
9
+ "https://#{account_name}.blob.core.windows.net/?restype=service&comp=userdelegationkey"
10
+ )
11
+
12
+ @signer = signer
13
+
14
+ refresh
15
+ end
16
+
17
+ def to_s
18
+ user_delegation_key
19
+ end
20
+
21
+ def refresh
22
+ return unless expired?
23
+ now = Time.now.utc
24
+
25
+
26
+ start = now.iso8601
27
+ @expiration = (now + EXPIRATION)
28
+ expiry = @expiration.iso8601
29
+
30
+ content = <<-XML.gsub!(/[[:space:]]+/, " ").strip!
31
+ <?xml version="1.0" encoding="utf-8"?>
32
+ <KeyInfo>
33
+ <Start>#{start}</Start>
34
+ <Expiry>#{expiry}</Expiry>
35
+ </KeyInfo>
36
+ XML
37
+
38
+ response = Http.new(uri, signer:).post(content)
39
+
40
+ doc = REXML::Document.new(response)
41
+
42
+ @signed_oid = doc.get_elements("/UserDelegationKey/SignedOid").first.get_text.to_s
43
+ @signed_tid = doc.get_elements("/UserDelegationKey/SignedTid").first.get_text.to_s
44
+ @signed_start = doc.get_elements("/UserDelegationKey/SignedStart").first.get_text.to_s
45
+ @signed_expiry = doc.get_elements("/UserDelegationKey/SignedExpiry").first.get_text.to_s
46
+ @signed_service = doc.get_elements("/UserDelegationKey/SignedService").first.get_text.to_s
47
+ @signed_version = doc.get_elements("/UserDelegationKey/SignedVersion").first.get_text.to_s
48
+ @user_delegation_key = Base64.decode64(doc.get_elements("/UserDelegationKey/Value").first.get_text.to_s)
49
+ end
50
+
51
+ attr_reader :signed_oid,
52
+ :signed_tid,
53
+ :signed_start,
54
+ :signed_expiry,
55
+ :signed_service,
56
+ :signed_version,
57
+ :user_delegation_key
58
+
59
+ private
60
+
61
+ def expired?
62
+ expiration.nil? || Time.now >= (expiration - EXPIRATION_BUFFER)
63
+ end
64
+
65
+ attr_reader :uri, :user_delegation_key, :signer, :expiration
66
+ end
67
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AzureBlob
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/main.tf ADDED
@@ -0,0 +1,187 @@
1
+ terraform {
2
+ required_providers {
3
+ azurerm = {
4
+ source = "hashicorp/azurerm"
5
+ version = "~>3.0"
6
+ }
7
+ }
8
+ }
9
+
10
+ provider "azurerm" {
11
+ features {}
12
+ }
13
+
14
+ locals {
15
+ public_ssh_key = var.ssh_key != "" ? var.ssh_key : file("~/.ssh/id_rsa.pub")
16
+ }
17
+
18
+ resource "azurerm_resource_group" "main" {
19
+ name = var.prefix
20
+ location = var.location
21
+ tags = {
22
+ source = "Terraform"
23
+ }
24
+ }
25
+
26
+ resource "azurerm_storage_account" "main" {
27
+ name = var.storage_account_name
28
+ resource_group_name = azurerm_resource_group.main.name
29
+ location = azurerm_resource_group.main.location
30
+ account_tier = "Standard"
31
+ account_replication_type = "LRS"
32
+
33
+ tags = {
34
+ source = "Terraform"
35
+ }
36
+ }
37
+
38
+ resource "azurerm_storage_container" "private" {
39
+ name = "private"
40
+ storage_account_name = azurerm_storage_account.main.name
41
+ container_access_type = "private"
42
+ }
43
+
44
+ resource "azurerm_storage_container" "public" {
45
+ name = "public"
46
+ storage_account_name = azurerm_storage_account.main.name
47
+ container_access_type = "blob"
48
+ }
49
+
50
+ resource "azurerm_virtual_network" "main" {
51
+ count = var.create_vm ? 1 : 0
52
+ name = "${var.prefix}-network"
53
+ address_space = ["10.0.0.0/16"]
54
+ location = azurerm_resource_group.main.location
55
+ resource_group_name = azurerm_resource_group.main.name
56
+
57
+ tags = {
58
+ source = "Terraform"
59
+ }
60
+ }
61
+
62
+ resource "azurerm_subnet" "main" {
63
+ count = var.create_vm ? 1 : 0
64
+ name = "${var.prefix}-main"
65
+ resource_group_name = azurerm_resource_group.main.name
66
+ virtual_network_name = azurerm_virtual_network.main[0].name
67
+ address_prefixes = ["10.0.2.0/24"]
68
+ }
69
+
70
+ resource "azurerm_network_interface" "main" {
71
+ count = var.create_vm ? 1 : 0
72
+ name = "${var.prefix}-nic"
73
+ location = azurerm_resource_group.main.location
74
+ resource_group_name = azurerm_resource_group.main.name
75
+
76
+ ip_configuration {
77
+ name = "${var.prefix}-ip-config"
78
+ subnet_id = azurerm_subnet.main[0].id
79
+ private_ip_address_allocation = "Dynamic"
80
+ public_ip_address_id = azurerm_public_ip.main[0].id
81
+ }
82
+
83
+ tags = {
84
+ source = "Terraform"
85
+ }
86
+ }
87
+
88
+ resource "azurerm_public_ip" "main" {
89
+ count = var.create_vm ? 1 : 0
90
+ name = "${var.prefix}-public-ip"
91
+ resource_group_name = azurerm_resource_group.main.name
92
+ location = azurerm_resource_group.main.location
93
+ allocation_method = "Static"
94
+
95
+ tags = {
96
+ source = "Terraform"
97
+ }
98
+ }
99
+
100
+ resource "azurerm_user_assigned_identity" "vm" {
101
+ location = azurerm_resource_group.main.location
102
+ name = "${var.prefix}-vm"
103
+ resource_group_name = azurerm_resource_group.main.name
104
+ }
105
+
106
+
107
+ resource "azurerm_role_assignment" "vm" {
108
+ scope = azurerm_storage_account.main.id
109
+ role_definition_name = "Storage Blob Data Contributor"
110
+ principal_id = azurerm_user_assigned_identity.vm.principal_id
111
+ }
112
+
113
+ resource "azurerm_linux_virtual_machine" "main" {
114
+ count = var.create_vm ? 1 : 0
115
+ name = "${var.prefix}-vm"
116
+ computer_name = var.prefix
117
+ resource_group_name = azurerm_resource_group.main.name
118
+ location = azurerm_resource_group.main.location
119
+ size = var.vm_size
120
+ admin_username = var.vm_username
121
+ admin_password = var.vm_password
122
+ disable_password_authentication = true
123
+ network_interface_ids = [azurerm_network_interface.main[0].id]
124
+
125
+ identity {
126
+ type = "UserAssigned"
127
+ identity_ids = [azurerm_user_assigned_identity.vm.id]
128
+ }
129
+
130
+ admin_ssh_key {
131
+ username = var.vm_username
132
+ public_key = local.public_ssh_key
133
+ }
134
+
135
+ source_image_reference {
136
+ publisher = "Canonical"
137
+ offer = "0001-com-ubuntu-server-jammy"
138
+ sku = "22_04-lts"
139
+ version = "latest"
140
+ }
141
+
142
+ os_disk {
143
+ caching = "ReadWrite"
144
+ storage_account_type = "Standard_LRS"
145
+ }
146
+
147
+ tags = {
148
+ source = "Terraform"
149
+ }
150
+ }
151
+
152
+ resource "azurerm_service_plan" "main" {
153
+ count = var.create_app_service ? 1 : 0
154
+ name = "${var.prefix}-appserviceplan"
155
+ resource_group_name = azurerm_resource_group.main.name
156
+ location = azurerm_resource_group.main.location
157
+ os_type = "Linux"
158
+ sku_name = "B1"
159
+ }
160
+
161
+ resource "azurerm_linux_web_app" "main" {
162
+ count = var.create_app_service ? 1 : 0
163
+ name = "${var.prefix}-app"
164
+ service_plan_id = azurerm_service_plan.main[0].id
165
+ resource_group_name = azurerm_resource_group.main.name
166
+ location = azurerm_resource_group.main.location
167
+
168
+ identity {
169
+ type = "UserAssigned"
170
+ identity_ids = [azurerm_user_assigned_identity.vm.id]
171
+ }
172
+
173
+ site_config {
174
+ application_stack {
175
+ node_version = "20-lts"
176
+ }
177
+ }
178
+ }
179
+
180
+ resource "azurerm_app_service_source_control" "main" {
181
+ count = var.create_app_service ? 1 : 0
182
+ app_id = azurerm_linux_web_app.main[0].id
183
+ repo_url = "https://github.com/Azure-Samples/nodejs-docs-hello-world"
184
+ branch = "master"
185
+ use_manual_integration = true
186
+ use_mercurial = false
187
+ }
data/output.tf ADDED
@@ -0,0 +1,30 @@
1
+ output "devenv_local_nix" {
2
+ sensitive = true
3
+ value = <<EOT
4
+ {pkgs, lib, ...}:{
5
+ env = {
6
+ AZURE_ACCOUNT_NAME = "${azurerm_storage_account.main.name}";
7
+ AZURE_ACCESS_KEY = "${azurerm_storage_account.main.primary_access_key}";
8
+ AZURE_PRIVATE_CONTAINER = "${azurerm_storage_container.private.name}";
9
+ AZURE_PUBLIC_CONTAINER = "${azurerm_storage_container.public.name}";
10
+ AZURE_PRINCIPAL_ID = "${azurerm_user_assigned_identity.vm.principal_id}";
11
+ };
12
+ }
13
+ EOT
14
+ }
15
+
16
+ output "vm_ip" {
17
+ value = var.create_vm ? azurerm_public_ip.main[0].ip_address : ""
18
+ }
19
+
20
+ output "vm_username" {
21
+ value = var.vm_username
22
+ }
23
+
24
+ output "app_service_app_name" {
25
+ value = var.create_app_service ? azurerm_linux_web_app.main[0].name : ""
26
+ }
27
+
28
+ output "resource_group" {
29
+ value = azurerm_resource_group.main.name
30
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: azure-blob
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joé Dupuis
@@ -34,6 +34,7 @@ files:
34
34
  - ".envrc"
35
35
  - ".rubocop.yml"
36
36
  - ".standard.yml"
37
+ - ".terraform.lock.hcl"
37
38
  - CHANGELOG.md
38
39
  - LICENSE.txt
39
40
  - README.md
@@ -43,6 +44,7 @@ files:
43
44
  - devenv.lock
44
45
  - devenv.nix
45
46
  - devenv.yaml
47
+ - input.tf
46
48
  - lib/active_storage/service/azure_blob_service.rb
47
49
  - lib/azure_blob.rb
48
50
  - lib/azure_blob/blob.rb
@@ -52,15 +54,21 @@ files:
52
54
  - lib/azure_blob/canonicalized_resource.rb
53
55
  - lib/azure_blob/client.rb
54
56
  - lib/azure_blob/const.rb
57
+ - lib/azure_blob/entra_id_signer.rb
55
58
  - lib/azure_blob/errors.rb
56
59
  - lib/azure_blob/http.rb
60
+ - lib/azure_blob/identity_token.rb
57
61
  - lib/azure_blob/metadata.rb
58
- - lib/azure_blob/signer.rb
62
+ - lib/azure_blob/shared_key_signer.rb
63
+ - lib/azure_blob/user_delegation_key.rb
59
64
  - lib/azure_blob/version.rb
65
+ - main.tf
66
+ - output.tf
60
67
  homepage: https://github.com/testdouble/azure-blob
61
68
  licenses:
62
69
  - MIT
63
70
  metadata:
71
+ rubygems_mfa_required: 'true'
64
72
  homepage_uri: https://github.com/testdouble/azure-blob
65
73
  source_code_uri: https://github.com/testdouble/azure-blob
66
74
  changelog_uri: https://github.com/testdouble/azure-blob/blob/main/CHANGELOG.md