Skip to content

Ory Keto: Authorization and Access Control as a Service

Internet has come a long way since its inception. The first few years might have been a new adventure for those building web applications, but in the modern day software development and in 2024, you rarely stop to question most of the common practices around the industry.

One of the most frequent requirement for any application is to have some sort of access control policy. The most used approach in today's world is the use of RBAC. It makes a lot of sense to treat a group of one or multiple identities of a system the same way and grant or deny them a specific set of permissions.

Ory Keto comes with all the batteries included. It provides a fearless authorization platform, friendly API for developers, and scalable stateless application.

If you're creating an application over HTTP these days, chances are, Ory Keto has a lot to offer you. Stick around till the end to find out how.

Introduction

Today's software development is rarely just the software itself. We all get tangled up on all the other aspects of production-readiness and the ever so famous checklist.

We find ourselves doing application development only 20% of the time. The rest gets us all so busy with the never ending yak-shavings1.

Fortunately for us, Ory comes with a bundle of plug-and-play products to make our lives easier. We will have one less aspect to worry about when it comes to securing our application in the wild world out there.

With Ory Keto, you can grant or deny access to your application in a flexible manner, customize the permission sets as required, and grow effortlessly as your application scales.

What is Ory Keto?

Ory Keto is a one-stop shop for all the authorization needs. With Keto, you can define policies, roles, and permissions for your application. These policies can be written either using a programming language SDK in the Ory Permission Language(OPL)2, or a configuration file in JSON or YAML3.

It has a friendly REST API4 that you can use to query or modify the policies on the fly.

In short, with Ory Keto you can answer the second most important question:

Is the user/identity X allowed to access the resource Y?

In that the first one is: Is the request authenticated/logged-in?

We will get to the nitty gritty details of how it identifies how to answer this under the hood in a bit, but the important thing to mention here is that it simplifies the authorization problem by centralizing the policies and consolidating the definitions in one place.

Why Ory Keto?

Keto is not the only authorization solution out there. The reality is that there are countless other alternatives, each with their own strength and flexibility. You may end up getting lost finding the right fit for your setup!

There are programming language authorization libraries such as Casbin5 and OPA6. There are cloud-based solutions such as Auth07 and Okta8.

In my experience managing production workloads over the years, I have found that the authorization is mostly an operational concern.

Although some folks may disagree, I have found that taking the authorization out of the application simplifies the maintainability and long-term success of the application, allowing the developers focusing on increasing the success and richness of the business logic.

However, when placing RBAC and other authorization mechanisms inside the application, you'll end up with a lot of code that is mostly relevant to the production environment and only slows the developers down when working locally.

Why is that an issue? one might ask. Well, imagine having to populate your access policy documents at the start of the application on each local development. That's a waste of computation and engineering time.

On top of that, every time a new member joins the team, you end up having to explain the authorization mechanism to them and how to set the whole thing up.

We may get clever automating the process by creating a migration step for the authorization policies. However, that only pushes the problem to a different layer rather than solving it.

All in all, Ory Keto is a great place to offload such a tedious task, and it comes with a lot of flexibility in the operational and admin layer.

Without Keto, you'd end up waiting a long time for a change to the application code to reflect the new access control policy. With Keto, you can make the change in the operational layer and have it reflected in the production instantly.

All that's require with Keto is an API call to the admin endpoint when managing the authorization of your platform at the operational level, using an authorization as a service tool such as Ory Keto.

Disclaimer

This blog post is NOT sponsored by Ory(1). I'm just a happy user of their products and I want to share my experience with you.

  1. Though, I definitely wouldn't mind seeing some dollars. 🤑

How Does Ory Keto Work?

If you've worked with RBAC systems before, understanding the inner workings of Ory Keto should be a piece of cake for you. 🍰

It also closely resembles Linux file permissions9, in that you can assign users to groups, and allow them a certain level of access over files and directories.

To make the matters more clear, we are gonna use an illustration below.

Bear in mind that this diagram is loaning what we've built before with Ory Kratos and Ory Oathkeeper and you are more than welcome to give those a read as well to have the full picture.

sequenceDiagram
    participant User as User/Identity
    participant Proxy as Ory Oathkeeper
    participant Auth as Ory Kratos
    participant Authz as Ory Keto
    participant Upstream as Upstream Server

    User->>Proxy: Request access
    Proxy->>Auth: Is it authenticated/logged in?
    Auth->>User: Not authenticated: 401 unauthorized
    Proxy->>Authz: Is it authorized?
    Authz->>User: Not authorized: 403 forbidden
    Proxy->>Upstream: Forward request
    Upstream->>User: Response

Here are the steps, simplified for better understanding:

  1. The request comes into the system, Oathkeeper takes the request.
  2. The Oathkeeper will consult the Kratos to see if the request is authenticated.
  3. If the request is not authenticated, the user will get a 401 Unauthorized response, redirected to the login page, or another action based on the configuration.
  4. If the request is authenticated, the Oathkeeper will consult the Keto to see if the request is authorized.
  5. If the request is not authorized, the user will get a 403 Forbidden response, and the corresponding error message will be displayed.
  6. If the request is authorized, the Oathkeeper will forward the request to the upstream server, waits for the response and returns it to the user.

This flow will repeat for every request coming into the system. Since all of these services are stateless, you can scale them on-demand; the only bottleneck will be the SQL database running in the background and there different techniques to scale that as well10.

In our series covering the Ory products, we've already covered all the way until step 3. This blog post will cover the authorization part, step 4.

At the end of this article, you should be able to protect your upstream server from any unauthorized access, without the need to implement it manually in your code.

That, in effect, means that you can take any application in the wild, and protect it using only the operational layer, without touching the application, and without even needing to understand or modify its code.

How to Deploy self-hosted Ory Keto?

You don't necessarily have to deploy the Keto yourself to take it for a spin11. However, we are feeling nerdy here and can't help it. 🤓

Additionally, I, personally, have found the Ory's Helm charts12 to be extremely inflexible and hard to customize. You'd expect that using a template engine would allow your downstream users more wiggle room, but the sad reality is that in my opinion, there's a lot of room for improvement in their Helm charts13.

As such, we are using Kustomization in the following stack:

Here's the tree structure before getting into the code:

./keto/
├── deployment.yml
├── externalsecret.yml
├── httproute-read.yml
├── httproute-write.yml
├── keto-server-config.yml
├── kustomization.yml
├── kustomize.yml
├── service-read.yml
└── service-write.yml
keto/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: keto
spec:
  replicas: 1
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
    type: RollingUpdate
  template:
    spec:
      initContainers:
        - name: keto-automigrate
          image: oryd/keto
          command:
            - keto
          args:
            - migrate
            - up
            - --yes
            - --config
            - /etc/config/keto.yaml
          envFrom:
            - secretRef:
                name: keto-secrets
          volumeMounts:
            - name: keto-config
              mountPath: /etc/config/keto.yaml
              subPath: keto.yaml
              readOnly: true
      containers:
        - image: oryd/keto
          name: keto
          command:
            - keto
          args:
            - serve
            - --config
            - /etc/config/keto.yaml
          envFrom:
            - secretRef:
                name: keto-secrets
          ports:
            - containerPort: 4466
              name: keto-read
            - containerPort: 4467
              name: keto-write
          volumeMounts:
            - name: keto-config
              mountPath: /etc/config/keto.yaml
              subPath: keto.yaml
              readOnly: true
          resources: {}
      volumes:
        - name: keto-config
          configMap:
            name: keto-config
            optional: false
            items:
              - key: keto-server-config.yml
                path: keto.yaml
keto/externalsecret.yml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: keto-secrets
spec:
  data:
    - remoteRef:
        key: /keto/dsn
      secretKey: DSN
  refreshInterval: 24h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-parameter-store
keto/httproute-read.yml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: keto-read
spec:
  hostnames:
    - acl.developer-friendly.blog
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: developer-friendly-blog
      namespace: cert-manager
      sectionName: https
  rules:
    - backendRefs:
        - kind: Service
          name: keto-read
          port: 80
      filters:
        - responseHeaderModifier:
            set:
              - name: Strict-Transport-Security
                value: max-age=31536000; includeSubDomains; preload
          type: ResponseHeaderModifier
      matches:
        - path:
            type: PathPrefix
            value: /
keto/httproute-write.yml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: keto-write
spec:
  hostnames:
    - acl-admin.developer-friendly.blog
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: developer-friendly-blog
      namespace: cert-manager
      sectionName: https
  rules:
    - backendRefs:
        - kind: Service
          name: keto-write
          port: 80
      filters:
        - responseHeaderModifier:
            set:
              - name: Strict-Transport-Security
                value: max-age=31536000; includeSubDomains; preload
          type: ResponseHeaderModifier
      matches:
        - path:
            type: PathPrefix
            value: /

The most important part of the Keto server configuration below, besides all the operational configurations and boilerplates, is the namespace definition. More on that in a bit.

Also note the limit.max_read_depth which allows for the use of RBAC in our system.

keto/keto-server-config.yml
limit:
  max_read_depth: 3
log:
  format: json
  leak_sensitive_values: true
  level: info
namespaces:
  - id: 0
    name: roles
  - id: 1
    name: endpoints
profiling: cpu
serve:
  metrics:
    host: 0.0.0.0
  opl:
    host: 0.0.0.0
  read:
    host: 0.0.0.0
  write:
    host: 0.0.0.0
tracing:
  provider: jaeger
  providers:
    jaeger:
      local_agent_address: jaeger.monitoring:6831
      sampling:
        server_url: http://jaeger.monitoring:5778/sampling
        trace_id_ratio: 1
  service_name: keto
keto/kustomization.yml
configMapGenerator:
  - files:
      - ./keto-server-config.yml
    name: keto-config

images:
  - name: oryd/keto
    newTag: v0.12.0

resources:
  - externalsecret.yml
  - service-read.yml
  - service-write.yml
  - deployment.yml
  - httproute-read.yml
  - httproute-write.yml

commonLabels:
  app.kubernetes.io/component: keto
  app.kubernetes.io/instance: keto
  app.kubernetes.io/managed-by: Kustomize
  app.kubernetes.io/name: keto
  app.kubernetes.io/version: v0.12.0
keto/service-read.yml
apiVersion: v1
kind: Service
metadata:
  name: keto-read
spec:
  ports:
  - name: http
    port: 80
    protocol: TCP
    targetPort: keto-read
  type: ClusterIP
keto/service-write.yml
apiVersion: v1
kind: Service
metadata:
  name: keto-write
spec:
  ports:
  - name: http
    port: 80
    protocol: TCP
    targetPort: keto-write
  type: ClusterIP

Lastly, let's apply this stack:

keto/kustomize.yml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: keto
  namespace: flux-system
spec:
  force: false
  interval: 5m
  path: ./keto
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  targetNamespace: auth
  wait: true
kubectl apply -f keto/kustomize.yml

Once this stack is up, we can use the Keto CLI14 to check the connection to the server.

$ export KETO_READ_REMOTE=acl.developer-friendly.blog:443
$ export KETO_WRITE_REMOTE=acl-admin.developer-friendly.blog:443

$ keto status
SERVING

$ keto relation-tuple get
NAMESPACE       OBJECT  RELATION NAME   SUBJECT

NEXT PAGE TOKEN
IS LAST PAGE    true

What is the Namespace in Ory Keto?

We've been putting it off for a while, but it's time to address the elephant in the room 🐘. The namespace in Keto is the most important concept you should be aware of to understand the model of the authorization Keto provides.

It is this concept that allows you to create a multi-tenant system, where each tenant can have its own set of policies, roles, and permissions.

It grants the ability to create hierarchical permission structures, RBAC, and ABAC models, and allows you to create a complex set of rules that can be applied to a specific tenant or a group of tenants.

Let's provide some concrete examples to make it more clear.

flowchart TB
    Bob([Bob])
    Alice([Alice])
    AuditBot([Audit Bot])
    Posts[/Posts/]
    Users[/Users/]

    Bob --> EditorGroup
    Alice --> AdminGroup
    EditorGroup --> |Write| Posts
    AdminGroup --> |Owner| Posts
    AdminGroup --> |Owner| Users
    AuditBot --> |Read-only| Users

    classDef permission fill:none,stroke:#333,stroke-width:2px;

    class Alice,Bob person;
    class AdminGroup,EditorGroup group;
    class Owner,Edit,Read permission;

Let's explain some of the highlights of this diagram before putting it all into code.

  • There are two groups of entities in our system, one identity in each.
  • Granting resource access to a group falls under the RBAC category, e.g., AdminGroup to Posts relationship.
  • Granting resource access to a specific identity falls under the ABAC category, e.g., AuditBot to Users relationship.

It is usually the case that RBAC is a more flexible and desirable permission model, especially at scale.

ABAC, however, is more granular and can be used to create a more fine-grained permission model, yet it might be harder to maintain over the long run.

Overall, it's not uncommon to see a combination of both models in a system.

What Are My Namespaces?

It's not unusual to get lost in what namespaces you have in your system. It can be a bit tricky to define the boundaries of a namespace.

However, there is one recipe that can help you understand and define those namespaces and it is this:

Whenever there is a relationship between a subject/group and an object, there is a namespace.

For example, in the diagram above, we have two namespaces: roles and endpoints.

That's because we have relationships between the identities and the the groups we want to assign them to (roles).

We also have relationships between the groups and the resources we want to grant them access to (endpoints).

Whenever you get lost in defining your namespaces, remind yourself that the key here is the relationship.

Let's define the relationships in the following sections. These are the files you are going to see:

./permissions/
├── admin-rbac.json
├── auditbot-rbac.json
├── editor-rbac.json
└── members.json

Role-Based Access Control (RBAC)

Now that we know what is and isn't a namespace, let's create our groups and their members in the Keto server15.

permissions/members.json
[
  {
    "namespace": "roles",
    "object": "admin",
    "relation": "member",
    "subject_id": "[email protected]"
  },
  {
    "namespace": "roles",
    "object": "editor",
    "relation": "member",
    "subject_id": "[email protected]"
  }
]

Now, let's grant permissions to our groups.

permissions/admin-rbac.json
[
  {
    "namespace": "endpoints",
    "object": "/api/v1/posts",
    "relation": "POST",
    "subject_set": {
      "namespace": "roles",
      "object": "admin",
      "relation": "member"
    }
  },
  {
    "namespace": "endpoints",
    "object": "/api/v1/posts",
    "relation": "GET",
    "subject_set": {
      "namespace": "roles",
      "object": "admin",
      "relation": "member"
    }
  },
  {
    "namespace": "endpoints",
    "object": "/api/v1/users",
    "relation": "POST",
    "subject_set": {
      "namespace": "roles",
      "object": "admin",
      "relation": "member"
    }
  },
  {
    "namespace": "endpoints",
    "object": "/api/v1/users",
    "relation": "GET",
    "subject_set": {
      "namespace": "roles",
      "object": "admin",
      "relation": "member"
    }
  }
]
permissions/editor-rbac.json
[
  {
    "namespace": "endpoints",
    "object": "/api/v1/posts",
    "relation": "POST",
    "subject_set": {
      "namespace": "roles",
      "object": "editor",
      "relation": "member"
    }
  },
  {
    "namespace": "endpoints",
    "object": "/api/v1/posts",
    "relation": "GET",
    "subject_set": {
      "namespace": "roles",
      "object": "editor",
      "relation": "member"
    }
  }
]

These permissions define the following relationships:

  • Any identity in the EditorGroup can read and write to the Posts endpoint.
  • Any identity in the AdminGroup can read and write to the Posts endpoint.
  • Any identity in the AdminGroup can read and write to the Users endpoint.

If we did not define the two namespaces in the Keto server configuration, we'd get a 404 Not Found error when trying to create these permissions.

NOTE: The values we specified for relation and object are dynamic and can be customized to reflect our business logic. The value of the namespace, however, is static and should be defined in the server configuration beforehand.

Attribute-Based Access Control (ABAC)

Let's briefly switch gears and create our single permission for the AuditBot.

The idea is that our AuditBot should be able to view the users of the system. This will allow for auditing the system without having to have access to the user's data.

permissions/auditbot-abac.json
[
  {
    "namespace": "endpoints",
    "object": "/api/v1/users",
    "relation": "GET",
    "subject_id": "[email protected]"
  }
]

Here, we are not adding the audit bot to any group. Instead, we are granting the permission directly to the identity.

The reason why ABAC can be harder to maintain is that you have to keep track of all the identities and their permissions in the system. This can be a daunting task as the number of identities in a system grows.

These permissions and relationships can be created via either HTTP request to the Keto server admin API (as you see below), or from inside your application code using the available SDKs16.

$ keto relation-tuple create ./permissions/
NAMESPACE       OBJECT          RELATION NAME   SUBJECT
endpoints       /api/v1/posts   POST            roles:admin#member
endpoints       /api/v1/posts   GET             roles:admin#member
endpoints       /api/v1/users   POST            roles:admin#member
endpoints       /api/v1/users   GET             roles:admin#member
endpoints       /api/v1/users   GET             [email protected]
endpoints       /api/v1/posts   POST            roles:editor#member
endpoints       /api/v1/posts   GET             roles:editor#member
roles           admin           member          [email protected]
roles           editor          member          [email protected]

For example, if a new user signs up to the application, you can decide whether or not to add them to a specific group, or grant them a certain permission.

After this initial permission creation, Ory products will take care of the authentication and authorization for you, without any request ever reaching your application if it is not meant to.

Query the Permission Engine

We have created our demo permissions and groups. Now, let's verify that the permissions are working as expected.

We will combine the the three flagship products of Ory for an integrated auth solution in a bit, but let's query the Keto server directly for now 17.

curl -X POST \
  https://acl.developer-friendly.blog/relation-tuples/check \
  -Hcontent-type:application/json \
  -d'{"namespace":"endpoints",
      "object":"/api/v1/users",
      "relation":"POST",
      "subject_id":"[email protected]"}' \
  -D -

The result of this query will be a 200 OK with the following response:

{"allowed":true}

Noticed the beauty? The query is asking for a write permission for an email address we never explicitly granted access.

However, the Keto permission and policy engine will recurse through the groups until the maximum of predefined max-depth is reached18. If no permission matches, a 403 Forbidden will be returned.

In the same HTTP query, you can specify max-depth as query parameter, however the hard limit of that number is still the configured global value19.

flowchart TB
    alice([[email protected]])
    Users[/Users/]

    alice --> |Member of| AdminGroup["Admin Group"]
    AdminGroup --> |Write permission| Users

Verify the Permissions and Access Control

As before, we're heavily relying on our previously built stack on the Ory series. If you need a refresher, give them a look before proceeding.

Let's build the Oathkeeper rule as a Kubernetes CRD to consult the Keto server for the permissions.

Applying the following CRD resources, the Oathkeeper Maester20 will inform the Oathkeeper server using the following process:

  1. Update the corresponding ConfigMap with the new rules.
  2. Upon that update, the mounted volume will trigger a reload in the Oathkeeper server, resulting in the new rules being applied.
rules/echo-server.yml
apiVersion: oathkeeper.ory.sh/v1alpha1
kind: Rule
metadata:
  name: echo-server
spec:
  authenticators:
    - handler: cookie_session
      config:
        check_session_url: http://kratos-public.auth/sessions/whoami
        extra_from: "@this"
        force_method: GET
        only:
          - ory_kratos_session
        subject_from: identity.id
  authorizer:
    config:
      remote: http://keto-read.auth/relation-tuples/check
      payload: |
        {
          "namespace": "endpoints",
          "object": "{{ print .MatchContext.URL.EscapedPath }}",
          "relation": "{{ print .MatchContext.Method }}",
          "subject_id": "{{ print .Extra.identity.traits.email }}"
        }
    handler: remote_json
  errors:
    - handler: json
  match:
    methods:
      - POST
      - PUT
      - DELETE
      - PATCH
      - GET
    url: https://echo.developer-friendly.blog/api/v1/users<.*>
  mutators:
    - handler: header
      config:
        headers:
          x-user-id: "{{ print .Subject }}"
          x-user-email: "{{ print .Extra.identity.traits.email }}"
  upstream:
    preserveHost: true
    url: http://echo-server.default

Let's break down this rule for a better understanding.

Authenticator Handler (Kratos)

We have discussed the authenticator handler in our Kratos blog post. Briefly, the authenticator handler is responsible for checking whether or not the request is logged in.

Authorizer Handler (Keto)

The authorizer config you see in this rule is exactly identical to the curl command we had earlier. The only difference being that with Kubernetes CRD, we are consulting the Keto server using the in-cluster Kubernetes Service address.

  authorizer:
    config:
      remote: http://keto-read.auth/relation-tuples/check
      payload: |
        {
          "namespace": "endpoints",
          "object": "{{ print .MatchContext.URL.EscapedPath }}",
          "relation": "{{ print .MatchContext.Method }}",
          "subject_id": "{{ print .Extra.identity.traits.email }}"
        }
    handler: remote_json
curl -X POST \
  https://acl.developer-friendly.blog/relation-tuples/check \
  -Hcontent-type:application/json \
  -d'{"namespace":"endpoints",
      "object":"/api/v1/users",
      "relation":"POST",
      "subject_id":"[email protected]"}' \
  -D -

The placeholder in the authorizer rule is benefiting from the Go template language as specified in the official Ory documentation21 & the Official Go net/url package22.

Matching Strategy (Oathkeeper)

The match clause makes sure to only apply this rule to the HTTP requests targeting this address:

https://echo.developer-friendly.blog/api/v1/users<.*>

Notice that the <.*> is a regex pattern that is only effective if access_rules.matching_strategy: regexp in Oathkeeper configuration is set.

For an HTTP request, this matching is quite comprehensive:

rules/echo-server.yml
  match:
    methods:
      - POST
      - PUT
      - DELETE
      - PATCH
      - GET
    url: https://echo.developer-friendly.blog/api/v1/users<.*>

Mutator Handler (Oathkeeper)

This is an optional field. You can decide to add handler: noop to avoid the rule touching the request.

However, adding the subject ID or any other header to the request can be hugely benefitial to your upstream servers since they will have access to these information without paying the extra cost of a network roundtrip or a database query.

rules/echo-server.yml
  mutators:
    - handler: header
      config:
        headers:
          x-user-id: "{{ print .Subject }}"
          x-user-email: "{{ print .Extra.identity.traits.email }}"

Upstream Handler (Oathkeeper)

The last part of this equation is to forward the request to the upstream server. You can choose to keep the HTTP Host header or change it to the value specified in your Rule CRD.

Since the value of the Host is almost always coming from the end-user, it is a good idea to keep it as is, unless you have a specific use-case to change it.

rules/echo-server.yml
  upstream:
    preserveHost: true
    url: http://echo-server.default

Beware that the value of the upstream.url consists of the Kubernetes Service name in the format of http://<service-name>.<namespace>.svc.cluster.local. The last three are optional!

This echo-server is a Rust 🦀 application written by us. Take a look at our previous article for its deployment definition.

Jaeger

If you configure the tracing of Ory products, you will have a view in your Jaeger dashboard similar to what you see below.

Jaeger Dashboard
Jaeger Dashboard

Conclusion

In this blog post, we've configured our Keto server to handle the authorization of our application. With our previously deployed Kratos server and Oathkeeper, we managed to protect our upstream service from all the unauthenticated and unauthorized requests.

With the knowledge you have gathered here, you are well on your way to be able to build a secure, scalable, and maintainable application, while still being able to keep the operational aspect of your application outside the source code, hugely benefiting yourself, your team and the entire long-term success of your product.

These days, I rarely try to build my own authentication and authorization into the application. There are many great tools out there that has truly passed the test of time and are battle-tested.

Using either of these tools greatly simplifies your processes and I, for one, am one to believe that "our auth need are very special and need in-house development" is nothing but a load of baloney. 💩

I would recommend everyone in the industry to at least give Ory products a fair shot before trying to do a sloppy work at reinventing the wheel and shooting themselves in the foot. 🔫

You may be surprised how comprehensive their suite of products are and how they can help you build your app faster and worry about nonsensical aspects less.

I hope you have enjoyed this article as well as I did writing it.

Happy hacking and until next time 🫡, ciao. 🐧 🦀

If you enjoyed this blog post, consider sharing it with these buttons 👇. Please leave a comment for us at the end, we read & love 'em all. ❣

Share on Share on Share on Share on

Comments