azure-blob 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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